diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index d24dacc..6c1f2f2 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -4,6 +4,8 @@ from pathlib import Path import os import re import uuid +import asyncio +import time from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials @@ -48,7 +50,7 @@ def convert_message(msg: Message) -> dict: "id": reaction.user_id, "username": reaction.user.username }) - + return { "id": msg.id, "content": msg.content, @@ -88,7 +90,7 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: "id": reaction.user_id, "username": reaction.user.username }) - + return { "id": envelope.id, "senderId": envelope.sender_id, @@ -455,15 +457,15 @@ async def get_dm_conversations(current_user: User = Depends(get_current_user), d conversations_query = db.query(DMEnvelope).filter( (DMEnvelope.sender_id == current_user.id) | (DMEnvelope.recipient_id == current_user.id) ).order_by(DMEnvelope.timestamp.desc()) - + # Group by the "other user" (not current user) and get latest message conversations = {} for envelope in conversations_query: other_user_id = envelope.recipient_id if envelope.sender_id == current_user.id else envelope.sender_id - + if other_user_id not in conversations: conversations[other_user_id] = envelope - + # Get user info for each conversation result = [] for other_user_id, latest_message in conversations.items(): @@ -475,16 +477,16 @@ async def get_dm_conversations(current_user: User = Depends(get_current_user), d DMEnvelope.recipient_id == current_user.id, DMEnvelope.id > getattr(latest_message, 'last_read_id', 0) # This would need to be stored somewhere ).count() - + result.append({ "user": convert_user(other_user), "lastMessage": convert_dm_envelope(latest_message), "unreadCount": unread_count }) - + # Sort by latest message timestamp result.sort(key=lambda x: x["lastMessage"]["timestamp"], reverse=True) - + return { "status": "success", "conversations": result @@ -499,22 +501,19 @@ async def edit_message( db: Session = Depends(get_db) ): message = db.query(Message).filter(Message.id == message_id).first() - + if not message: raise HTTPException(status_code=404, detail="Message not found") - if message.user_id != current_user.id: raise HTTPException(status_code=403, detail="You can only edit your own messages") - if not request.content.strip(): raise HTTPException(status_code=400, detail="Message content cannot be empty") - message.content = request.content.strip() message.is_edited = True - + db.commit() db.refresh(message) - + return {"status": "success", "message": convert_message(message)} @@ -525,17 +524,17 @@ async def delete_message( db: Session = Depends(get_db) ): message = db.query(Message).filter(Message.id == message_id).first() - + if not message: raise HTTPException(status_code=404, detail="Message not found") - + # Allow owner to delete any message if current_user.username != OWNER_USERNAME and message.user_id != current_user.id: raise HTTPException(status_code=403, detail="You can only delete your own messages") - + db.delete(message) db.commit() - + return {"status": "success", "message_id": message_id} @@ -549,14 +548,14 @@ async def add_reaction( message = db.query(Message).filter(Message.id == request.message_id).first() if not message: raise HTTPException(status_code=404, detail="Message not found") - + # Check if reaction already exists existing_reaction = db.query(Reaction).filter( Reaction.message_id == request.message_id, Reaction.user_id == current_user.id, Reaction.emoji == request.emoji ).first() - + if existing_reaction: # Remove existing reaction (toggle off) db.delete(existing_reaction) @@ -570,12 +569,12 @@ async def add_reaction( ) db.add(new_reaction) action = "added" - + db.commit() - + # Refresh message to get updated reactions db.refresh(message) - + # Broadcast reaction update try: from .messaging import messagingManager @@ -592,7 +591,7 @@ async def add_reaction( }) except Exception: pass - + return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]} @@ -606,18 +605,18 @@ async def add_dm_reaction( envelope = db.query(DMEnvelope).filter(DMEnvelope.id == request.dm_envelope_id).first() if not envelope: raise HTTPException(status_code=404, detail="DM envelope not found") - + # Check if user is part of this DM conversation if current_user.id not in [envelope.sender_id, envelope.recipient_id]: raise HTTPException(status_code=403, detail="Not authorized to react to this message") - + # Check if reaction already exists existing_reaction = db.query(DMReaction).filter( DMReaction.dm_envelope_id == request.dm_envelope_id, DMReaction.user_id == current_user.id, DMReaction.emoji == request.emoji ).first() - + if existing_reaction: # Remove existing reaction (toggle off) db.delete(existing_reaction) @@ -631,15 +630,14 @@ async def add_dm_reaction( ) db.add(new_reaction) action = "added" - + db.commit() - + # Refresh envelope to get updated reactions db.refresh(envelope) - + # Broadcast reaction update to both participants try: - from .messaging import messagingManager await messagingManager.broadcast({ "type": "dmReactionUpdate", "data": { @@ -653,7 +651,7 @@ async def add_dm_reaction( }) except Exception: pass - + return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]} @@ -661,11 +659,19 @@ class MessaggingSocketManager: def __init__(self) -> None: self.connections: list[WebSocket] = [] self.user_by_ws: dict[WebSocket, int] = {} + self.online_users: set[int] = set() + self.typing_users: dict[int, float] = {} # user_id -> timestamp + self.dm_typing_users: dict[int, dict[int, float]] = {} # user_id -> {recipient_id -> timestamp} + self.ws_subscriptions: dict[WebSocket, set[int]] = {} # websocket -> set of subscribed user_ids + self._cleanup_task = None async def send_error(self, websocket: WebSocket, type: str, e: HTTPException): await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}}) async def handle_connection(self, websocket: WebSocket, db: Session): + # Initialize subscriptions for this connection + self.ws_subscriptions[websocket] = set() + while True: data = await websocket.receive_json() type = data["type"] @@ -674,9 +680,9 @@ class MessaggingSocketManager: if data["credentials"]: return get_current_user( HTTPAuthorizationCredentials( - scheme=data["credentials"]["scheme"], + scheme=data["credentials"]["scheme"], credentials=data["credentials"]["credentials"] - ), + ), db ) else: @@ -687,22 +693,30 @@ class MessaggingSocketManager: current_user = get_current_user_inner() if current_user: self.user_by_ws[websocket] = current_user.id + # Set user online in DB + current_user.online = True + current_user.last_seen = datetime.now() + db.commit() + # Add to online users + self.online_users.add(current_user.id) + # Broadcast status change + await self.broadcast_status_change(current_user.id, True, current_user.last_seen.isoformat()) else: await websocket.send_json({ - "type": "ping", + "type": "ping", "data": { - "status": "error", + "status": "error", "error": { - "detail": "Failed to authorize", + "detail": "Failed to authorize", "code": 401 } } }) except HTTPException: await websocket.send_json({ - "type": "ping", + "type": "ping", "data": { - "status": "error", + "status": "error", "error": { "detail": "Failed to authorize", "code": 401 @@ -726,7 +740,7 @@ class MessaggingSocketManager: if not current_user: raise HTTPException(401) self.user_by_ws[websocket] = current_user.id - + request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) response = await send_message(request, current_user, db, None, []) @@ -795,7 +809,7 @@ class MessaggingSocketManager: current_user = get_current_user_inner() if not current_user: raise HTTPException(401) - + message_id = data["data"]["message_id"] request: EditMessageRequest = EditMessageRequest.model_validate(data["data"]) @@ -885,7 +899,7 @@ class MessaggingSocketManager: current_user = get_current_user_inner() if not current_user: raise HTTPException(401) - + message_id = data["data"]["message_id"] response = await delete_message(message_id, current_user, db) await self.broadcast({ @@ -901,15 +915,15 @@ class MessaggingSocketManager: current_user = get_current_user_inner() if not current_user: raise HTTPException(401) - + request_data = data["data"] reaction_request = ReactionRequest( message_id=request_data["message_id"], emoji=request_data["emoji"] ) - + response = await add_reaction(reaction_request, current_user, db) - + # Broadcast reaction update await self.broadcast({ "type": "reactionUpdate", @@ -931,15 +945,15 @@ class MessaggingSocketManager: current_user = get_current_user_inner() if not current_user: raise HTTPException(401) - + request_data = data["data"] reaction_request = DMReactionRequest( dm_envelope_id=request_data["dm_envelope_id"], emoji=request_data["emoji"] ) - + response = await add_dm_reaction(reaction_request, current_user, db) - + # Broadcast reaction update await self.broadcast({ "type": "dmReactionUpdate", @@ -1040,15 +1054,144 @@ class MessaggingSocketManager: await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}}) except HTTPException as e: await self.send_error(websocket, type, e) + elif type == "subscribeStatus": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + user_id_to_subscribe = int(data["data"]["userId"]) + self.ws_subscriptions[websocket].add(user_id_to_subscribe) + + # Get current status of the user + target_user = db.query(User).filter(User.id == user_id_to_subscribe).first() + if target_user: + await websocket.send_json({ + "type": "statusUpdate", + "data": { + "userId": user_id_to_subscribe, + "online": target_user.online, + "lastSeen": target_user.last_seen.isoformat() + } + }) + else: + await websocket.send_json({ + "type": "subscribeStatus", + "data": {"status": "error", "error": "User not found"} + }) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "unsubscribeStatus": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + user_id_to_unsubscribe = int(data["data"]["userId"]) + self.ws_subscriptions[websocket].discard(user_id_to_unsubscribe) + + await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "typing": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + self.typing_users[current_user.id] = time.time() + + # Broadcast to all connected users + await self.broadcast({ + "type": "typing", + "data": { + "userId": current_user.id, + "username": current_user.username + } + }) + + await websocket.send_json({"type": "typing", "data": {"status": "ok"}}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "stopTyping": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + if current_user.id in self.typing_users: + del self.typing_users[current_user.id] + + # Broadcast to all connected users + await self.broadcast({ + "type": "stopTyping", + "data": { + "userId": current_user.id, + "username": current_user.username + } + }) + + await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "dmTyping": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + recipient_id = int(data["data"]["recipientId"]) + + if current_user.id not in self.dm_typing_users: + self.dm_typing_users[current_user.id] = {} + self.dm_typing_users[current_user.id][recipient_id] = time.time() + + # Send only to recipient + await self.send_to_user(recipient_id, { + "type": "dmTyping", + "data": { + "userId": current_user.id, + "username": current_user.username + } + }) + + await websocket.send_json({"type": "dmTyping", "data": {"status": "ok"}}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "stopDmTyping": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + recipient_id = int(data["data"]["recipientId"]) + + if current_user.id in self.dm_typing_users and recipient_id in self.dm_typing_users[current_user.id]: + del self.dm_typing_users[current_user.id][recipient_id] + if not self.dm_typing_users[current_user.id]: + del self.dm_typing_users[current_user.id] + + # Send only to recipient + await self.send_to_user(recipient_id, { + "type": "stopDmTyping", + "data": { + "userId": current_user.id, + "username": current_user.username + } + }) + + await websocket.send_json({"type": "stopDmTyping", "data": {"status": "ok"}}) + except HTTPException as e: + await self.send_error(websocket, type, e) else: await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}}) async def disconnect(self, websocket: WebSocket, code: int = 1000, message: str | None = None): try: await websocket.close(code=code, reason=message) - finally: + finally: self.connections.remove(websocket) - + async def connect(self, websocket: WebSocket, db: Session): await websocket.accept() self.connections.append(websocket) @@ -1057,9 +1200,24 @@ class MessaggingSocketManager: except WebSocketDisconnect as e: logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}") finally: + # Cleanup connection self.connections.remove(websocket) if websocket in self.user_by_ws: + user_id = self.user_by_ws[websocket] + # Set user offline in DB + user = db.query(User).filter(User.id == user_id).first() + if user: + user.online = False + user.last_seen = datetime.now() + db.commit() + # Remove from online users + self.online_users.discard(user_id) + # Broadcast status change + await self.broadcast_status_change(user_id, False, user.last_seen.isoformat()) del self.user_by_ws[websocket] + # Cleanup subscriptions + if websocket in self.ws_subscriptions: + del self.ws_subscriptions[websocket] async def broadcast(self, message: dict): for websocket in self.connections: @@ -1070,7 +1228,81 @@ class MessaggingSocketManager: if self.user_by_ws.get(websocket) == user_id: await websocket.send_json(message) + async def broadcast_status_change(self, user_id: int, online: bool, last_seen: str): + """Broadcast status change to all connections that are subscribed to this user""" + message = { + "type": "statusUpdate", + "data": { + "userId": user_id, + "online": online, + "lastSeen": last_seen + } + } + + # Send to all connections that have this user in their subscriptions + for websocket in self.connections: + if websocket in self.ws_subscriptions and user_id in self.ws_subscriptions[websocket]: + await websocket.send_json(message) + + async def cleanup_stale_typing_indicators(self): + """Periodically cleanup typing indicators that haven't been updated in 3+ seconds""" + while True: + try: + current_time = time.time() + stale_threshold = 3.0 # 3 seconds + + # Cleanup public chat typing indicators + stale_public_typing = [ + user_id for user_id, timestamp in self.typing_users.items() + if current_time - timestamp > stale_threshold + ] + + for user_id in stale_public_typing: + del self.typing_users[user_id] + # Broadcast stop typing + await self.broadcast({ + "type": "stopTyping", + "data": { + "userId": user_id, + "username": "Unknown" # We don't have username here, frontend will handle + } + }) + + # Cleanup DM typing indicators + stale_dm_typing = [] + for user_id, recipients in self.dm_typing_users.items(): + for recipient_id, timestamp in list(recipients.items()): + if current_time - timestamp > stale_threshold: + stale_dm_typing.append((user_id, recipient_id)) + + for user_id, recipient_id in stale_dm_typing: + if user_id in self.dm_typing_users and recipient_id in self.dm_typing_users[user_id]: + del self.dm_typing_users[user_id][recipient_id] + if not self.dm_typing_users[user_id]: + del self.dm_typing_users[user_id] + # Send stop typing to recipient + await self.send_to_user(recipient_id, { + "type": "stopDmTyping", + "data": { + "userId": user_id, + "username": "Unknown" # We don't have username here, frontend will handle + } + }) + + # Wait 1 second before next cleanup + await asyncio.sleep(1.0) + except Exception as e: + logger.error(f"Error in typing cleanup task: {e}") + await asyncio.sleep(1.0) + + def start_cleanup_task(self): + """Start the cleanup task if not already running""" + if self._cleanup_task is None or self._cleanup_task.done(): + self._cleanup_task = asyncio.create_task(self.cleanup_stale_typing_indicators()) + messagingManager = MessaggingSocketManager() +# Start the cleanup task +messagingManager.start_cleanup_task() @router.websocket("/chat/ws") async def chat_websocket( diff --git a/frontend/src/core/api/authApi.ts b/frontend/src/core/api/authApi.ts index ed9ea77..033f15b 100644 --- a/frontend/src/core/api/authApi.ts +++ b/frontend/src/core/api/authApi.ts @@ -35,8 +35,8 @@ async function fetchPublicKey(token: string): Promise { } async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise { - const payload: UploadPublicKeyRequest = { - publicKey: b64(publicKey) + const payload: UploadPublicKeyRequest = { + publicKey: b64(publicKey) } const headers = getAuthHeaders(token, true); @@ -49,9 +49,9 @@ async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise { const headers = getAuthHeaders(token, true); - const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { method: "GET", - headers + headers }); if (res.ok) { const response: BackupBlob = await res.json(); @@ -83,7 +83,7 @@ export function getCurrentKeys(): UserKeyPairMemory | null { } function saveKeys( - publicKey: Uint8Array, + publicKey: Uint8Array, privateKey: Uint8Array ) { const encodedPublicKey = b64(publicKey); @@ -117,9 +117,9 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis saveKeys(currentPublicKey!, currentPrivateKey!); - return { - publicKey: currentPublicKey!, - privateKey: currentPrivateKey! + return { + publicKey: currentPublicKey!, + privateKey: currentPrivateKey! }; } diff --git a/frontend/src/core/api/dmApi.ts b/frontend/src/core/api/dmApi.ts index e2003d8..632a40f 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -38,8 +38,8 @@ export async function fetchUserPublicKey(userId: number, token: string): Promise } export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { - const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { - headers: getAuthHeaders(token, true) + const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { + headers: getAuthHeaders(token, true) }); if (!response.ok) return []; const data = await response.json(); @@ -73,8 +73,8 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey await request({ type: "dmSend", - credentials: { - scheme: "Bearer", + credentials: { + scheme: "Bearer", credentials: authToken }, data: payload @@ -177,8 +177,8 @@ export interface DMConversationResponse { } export async function fetchDMConversations(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/dm/conversations`, { - headers: getAuthHeaders(token, true) + const res = await fetch(`${API_BASE_URL}/dm/conversations`, { + headers: getAuthHeaders(token, true) }); if (!res.ok) return []; const data = await res.json(); @@ -187,9 +187,9 @@ export async function fetchDMConversations(token: string): Promise { if (query.length < 2) return []; - - const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { - headers: getAuthHeaders(token, true) + + const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { + headers: getAuthHeaders(token, true) }); if (!res.ok) return []; const data = await res.json(); diff --git a/frontend/src/core/api/profileApi.ts b/frontend/src/core/api/profileApi.ts index 2e5a036..53c0962 100644 --- a/frontend/src/core/api/profileApi.ts +++ b/frontend/src/core/api/profileApi.ts @@ -30,7 +30,7 @@ export async function loadProfile(token: string): Promise { description: data.bio }; } - + return null; } catch (error) { console.error('Error loading profile:', error); @@ -119,7 +119,7 @@ export async function fetchUserProfile(token: string, username: string): Promise if (response.ok) { return await response.json(); } - + return null; } catch (error) { console.error('Error fetching user profile:', error); diff --git a/frontend/src/core/calls/e2eeWorker.ts b/frontend/src/core/calls/e2eeWorker.ts index d12a771..5dccd67 100644 --- a/frontend/src/core/calls/e2eeWorker.ts +++ b/frontend/src/core/calls/e2eeWorker.ts @@ -39,7 +39,7 @@ function makeIV(encodedFrame: EncodedFrame): ArrayBuffer { // Frame data can differ between sender/receiver due to encoding differences const ivBuffer = new ArrayBuffer(12); const view = new DataView(ivBuffer); - + if (encodedFrame.getMetadata) { try { const metadata = encodedFrame.getMetadata(); @@ -48,14 +48,14 @@ function makeIV(encodedFrame: EncodedFrame): ArrayBuffer { view.setUint32(0, metadata.rtpTimestamp, false); // First 4 bytes view.setUint32(4, metadata.synchronizationSource || 0, false); // Middle 4 bytes view.setUint32(8, 0, false); // Last 4 bytes (padding for 12-byte IV) - + return ivBuffer; } } catch (e) { console.error("Failed to get metadata:", e); } } - + // Fallback: use timestamp only (no random to avoid desync) view.setUint32(0, Date.now() & 0xFFFFFFFF, false); view.setUint32(4, 0, false); @@ -67,30 +67,30 @@ addEventListener("rtctransform", (event) => { const { transformer } = event; const { readable, writable } = transformer; const { key, mode } = transformer.options as WorkerOptions; - + const isEncrypting = mode === 'encrypt'; - + let frameCount = 0; - + async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController) { try { const data = new Uint8Array(encodedFrame.data); - + // Increment frame counter frameCount++; - + // Create IV using RTP timestamp from metadata (synchronized between peers) const iv = makeIV(encodedFrame); - + // Ensure IV is properly typed const ivArray = new Uint8Array(iv); const params: AesGcmParams = { name: 'AES-GCM', iv: ivArray }; - + // COMPROMISE: Encrypt most of the frame while preserving minimal codec compatibility // This prevents most visual leakage while maintaining decodability let headerSize = 0; let payloadData: Uint8Array; - + if (data.length > 20) { // For video frames, preserve first 8 bytes for better codec compatibility // This includes frame type, keyframe info, and basic header structure @@ -100,11 +100,11 @@ addEventListener("rtctransform", (event) => { // For small frames (likely audio), encrypt everything payloadData = data; } - + // Encrypt the payload data const payloadBuffer = new ArrayBuffer(payloadData.byteLength); new Uint8Array(payloadBuffer).set(payloadData); - + let encryptedPayload: ArrayBuffer; if (isEncrypting) { encryptedPayload = await crypto.subtle.encrypt(params, key, payloadBuffer); @@ -116,18 +116,18 @@ addEventListener("rtctransform", (event) => { return; // Drop the frame } } - + // Reconstruct frame: minimal headers + encrypted payload const encryptedArray = new Uint8Array(encryptedPayload); const result = new Uint8Array(headerSize + encryptedArray.length); - + if (headerSize > 0) { result.set(data.slice(0, headerSize), 0); // Copy minimal headers result.set(encryptedArray, headerSize); // Add encrypted payload } else { result.set(encryptedArray, 0); } - + // CRITICAL: Video frames need ArrayBuffer, not Uint8Array encodedFrame.data = result.buffer; controller.enqueue(encodedFrame); diff --git a/frontend/src/core/calls/encryption.ts b/frontend/src/core/calls/encryption.ts index 304768c..5c0c926 100644 --- a/frontend/src/core/calls/encryption.ts +++ b/frontend/src/core/calls/encryption.ts @@ -31,11 +31,11 @@ export interface EncryptedCallMessage { export async function generateCallSessionKey(): Promise { // Generate session key material const sessionKeyMaterial = randomBytes(32); - + // Generate hash for emoji display (first 4 bytes of SHA-256 hash) const hashBuffer = await crypto.subtle.digest("SHA-256", sessionKeyMaterial.buffer as ArrayBuffer); const hash = b64(new Uint8Array(hashBuffer.slice(0, 4))); - + return { key: sessionKeyMaterial, hash @@ -49,11 +49,11 @@ export async function generateCallSessionKey(): Promise { export async function rotateCallSessionKey(): Promise { // Generate new session key material (completely independent of current key) const newSessionKeyMaterial = randomBytes(32); - + // Generate new hash for emoji display const hashBuffer = await crypto.subtle.digest("SHA-256", newSessionKeyMaterial.buffer as ArrayBuffer); const newHash = b64(new Uint8Array(hashBuffer.slice(0, 4))); - + return { key: newSessionKeyMaterial, hash: newHash @@ -68,12 +68,12 @@ export async function createCallSessionKeyFromHash(hash: string): Promise { @@ -93,7 +93,7 @@ export async function deriveCallSessionKeyFromSharedSecret( // Include the session key hash and role to ensure uniqueness const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`); const salt = new Uint8Array(32); // Zero salt for deterministic derivation - + // Import the shared secret as a raw key for HKDF const sharedKey = await crypto.subtle.importKey( 'raw', @@ -102,7 +102,7 @@ export async function deriveCallSessionKeyFromSharedSecret( false, ['deriveKey'] ); - + // Derive the session key using HKDF const sessionKey = await crypto.subtle.deriveKey( { @@ -116,10 +116,10 @@ export async function deriveCallSessionKeyFromSharedSecret( true, // Make the key extractable so we can export it ['encrypt', 'decrypt'] ); - + // Export the raw key material const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey); - + return { key: new Uint8Array(sessionKeyMaterial), hash: sessionKeyHash @@ -132,7 +132,7 @@ export async function deriveCallSessionKeyFromSharedSecret( export async function encryptCallMessage(message: Record, sessionKey: Uint8Array): Promise { const messageKey = await importAesGcmKey(sessionKey); const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message))); - + return { iv: b64(encrypted.iv), ciphertext: b64(encrypted.ciphertext), @@ -158,7 +158,7 @@ export function generateCallEmojis(sessionKeyHash: string): string[] { // Convert hash to numbers and map to emoji ranges const hashBytes = new Uint8Array(ub64(sessionKeyHash)); const emojis: string[] = []; - + // Different emoji categories for variety const emojiSets = [ ["🎵", "🎶", "🎤", "🎧", "🎼", "🎹", "🥁", "🎺", "🎸", "🎻"], // Music @@ -166,13 +166,13 @@ export function generateCallEmojis(sessionKeyHash: string): string[] { ["🚀", "🛸", "🛰️", "🌌", "🔭", "⚙️", "🔧", "⚡", "💡", "🔬"], // Tech/Space ["🎭", "🎪", "🎨", "🎬", "📷", "🎥", "📺", "🎮", "🕹️", "🎯"] // Entertainment ]; - + for (let i = 0; i < 4; i++) { const set = emojiSets[i % emojiSets.length]; const index = hashBytes[i % hashBytes.length] % set.length; emojis.push(set[index]); } - + return emojis; } @@ -214,7 +214,7 @@ export async function createSharedSecretAndDeriveSessionKey( // Create shared secret using ECDH const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); - + // Derive the session key from the shared secret return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator); } diff --git a/frontend/src/core/calls/signaling.ts b/frontend/src/core/calls/signaling.ts index bc158c4..36f738c 100644 --- a/frontend/src/core/calls/signaling.ts +++ b/frontend/src/core/calls/signaling.ts @@ -69,10 +69,10 @@ export class CallSignalingHandler { const { fromUsername } = data; const fromUserId = message.fromUserId; const state = this.getState(); - + // First, create the peer connection in WebRTC service await WebRTC.handleIncomingCall(fromUserId, fromUsername); - + // Then show incoming call UI state.receiveCall(fromUserId, fromUsername); } @@ -96,12 +96,12 @@ export class CallSignalingHandler { private handleCallReject(data: CallRejectData) { const state = this.getState(); const { fromUserId } = data; - + // Clean up WebRTC connection first if (fromUserId) { WebRTC.cleanupCall(fromUserId); } - + // End the call state.endCall(); } @@ -121,12 +121,12 @@ export class CallSignalingHandler { private handleCallEnd(data: CallEndData) { const state = this.getState(); const { fromUserId } = data; - + // Clean up WebRTC connection first if (fromUserId) { WebRTC.cleanupCall(fromUserId); } - + // End the call state.endCall(); } @@ -144,7 +144,7 @@ export class CallSignalingHandler { private handleVideoToggle(message: CallSignalingMessage, data: CallVideoToggleData) { const state = this.getState(); - + if (data && typeof data.enabled === "boolean" && message.fromUserId) { // Update Zustand state (for UI) state.setRemoteVideoEnabled(data.enabled); @@ -157,7 +157,7 @@ export class CallSignalingHandler { private handleScreenShareToggle(message: CallSignalingMessage, data: CallScreenShareToggleData) { const state = this.getState(); - + if (data && typeof data.enabled === "boolean" && message.fromUserId) { // Update Zustand state (for UI) state.setRemoteScreenSharing(data.enabled); diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index 9e46562..1680908 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -69,7 +69,7 @@ export class WebRTCCall { private set sessionKey(value: Uint8Array | null) { this._sessionKey = value; } - + // ------------------- // Core initialization @@ -85,12 +85,12 @@ export class WebRTCCall { */ async initialize(): Promise { const iceServers = await this.getIceServers(); - + // Create peer connection with proper ICE servers this.peerConnection = new RTCPeerConnection({ iceServers }); - + this.setupEventListeners(); } @@ -102,7 +102,7 @@ export class WebRTCCall { const response = await fetch("/api/webrtc/ice", { headers: getAuthHeaders(getAuthToken()!) }); - + if (response.ok) { const data = await response.json() as IceServersResponse; return data.iceServers || []; @@ -112,7 +112,7 @@ export class WebRTCCall { } catch (error) { console.warn("Failed to fetch ICE servers:", error); } - + // Fallback to STUN only if backend fails return DEFAULT_ICE_SERVERS; } @@ -161,7 +161,7 @@ export class WebRTCCall { } this.isNegotiating = true; - + const offer = await this.peerConnection.createOffer(); await this.peerConnection.setLocalDescription(offer); @@ -184,7 +184,7 @@ export class WebRTCCall { const [remoteStream] = event.streams; if (remoteStream) { const track = event.track; - + // Apply E2EE transform to all tracks - video now uses header-preserving encryption if (this.sessionKey && window.RTCRtpScriptTransform) { try { @@ -198,12 +198,12 @@ export class WebRTCCall { console.error("Failed to apply E2EE to received track:", error); } } - + // Determine stream type based on track kind and signaling state if (track.kind === "video") { let isScreenShare = false; let isVideo = false; - + if (this.isRemoteScreenSharing && this.isRemoteVideoEnabled) { // Both active - route based on which one we haven't received yet // Simple logic: if we haven't received video yet, this is video @@ -225,7 +225,7 @@ export class WebRTCCall { isVideo = true; this.receivedVideoTrackCount++; } - + if (isScreenShare) { if (callbacks.onRemoteScreenShare) { callbacks.onRemoteScreenShare(this.remoteUserId, remoteStream); @@ -253,7 +253,7 @@ export class WebRTCCall { // Clean up only on permanent failures // Don't end on "disconnected" - ICE can recover from temporary disconnections - if (this.peerConnection.connectionState === "failed" || + if (this.peerConnection.connectionState === "failed" || this.peerConnection.connectionState === "closed") { // Only send end call message if we're not already cleaning up if (!this.isEnding) { @@ -284,32 +284,32 @@ export class WebRTCCall { audioTrack.stop(); this.localStream.removeTrack(audioTrack); } - + // Create a silent audio track using Web Audio API const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; const audioContext = new AudioContextClass(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); - + // Set gain to 0 (silent) gainNode.gain.setValueAtTime(0, audioContext.currentTime); - + // Connect nodes oscillator.connect(gainNode); - + // Create a MediaStreamDestination to get a MediaStream const destination = audioContext.createMediaStreamDestination(); gainNode.connect(destination); - + // Start the oscillator (but it's silent due to gain = 0) oscillator.start(); - + // Add the silent track to maintain WebRTC connection const silentTrack = destination.stream.getAudioTracks()[0]; if (silentTrack) { this.localStream.addTrack(silentTrack); } - + this.isMuted = true; return true; // Muted } else { @@ -318,15 +318,15 @@ export class WebRTCCall { .then(newStream => { // Remove any existing audio tracks from the stream this.localStream!.getAudioTracks().forEach(track => track.stop()); - + // Get the new active track const newAudioTrack = newStream.getAudioTracks()[0]; - + // Replace the track in the peer connection - const sender = this.peerConnection.getSenders().find(s => + const sender = this.peerConnection.getSenders().find(s => s.track && s.track.kind === 'audio' ); - + if (sender) { // Replace the track in the existing sender sender.replaceTrack(newAudioTrack); @@ -334,10 +334,10 @@ export class WebRTCCall { // Add the track to the peer connection if no sender exists this.peerConnection.addTrack(newAudioTrack, this.localStream!); } - + // Add the track to the local stream this.localStream!.addTrack(newAudioTrack); - + this.isMuted = false; }) .catch(error => { @@ -471,10 +471,10 @@ export class WebRTCCall { // Add screen share track to peer connection const videoTrack = screenStream.getVideoTracks()[0]; - + // Handle when user stops sharing via browser UI videoTrack.addEventListener("ended", async () => { - + // Clean up screen share state if (this.screenShareStream) { this.screenShareStream.getTracks().forEach(t => t.stop()); @@ -484,12 +484,12 @@ export class WebRTCCall { // Remove screen share track from peer connection const senders = this.peerConnection.getSenders(); - const screenSender = senders.find(sender => - sender.track && sender.track.kind === 'video' && + const screenSender = senders.find(sender => + sender.track && sender.track.kind === 'video' && sender.track.readyState === 'ended' && this.transformedSenders.has(sender) ); - + if (screenSender) { await this.peerConnection.removeTrack(screenSender); this.transformedSenders.delete(screenSender); @@ -534,7 +534,7 @@ export class WebRTCCall { try { const key = await importAesGcmKey(this.sessionKey); const sender = this.peerConnection.getSenders().find(s => s.track === videoTrack); - + if (sender && !this.transformedSenders.has(sender)) { sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: this.sessionId }); this.transformedSenders.add(sender); @@ -604,14 +604,14 @@ export class WebRTCCall { */ async setSessionKey(keyBytes: Uint8Array): Promise { this.sessionKey = keyBytes; - + await this.applyE2EETransforms(); - + // Start key rotation timer (rotate every 10 minutes for long calls) if (this.keyRotationTimer) { clearInterval(this.keyRotationTimer); } - + this.keyRotationTimer = setInterval(async () => { await this.rotateSessionKey(); }, KEY_ROTATION_INTERVAL); @@ -622,14 +622,14 @@ export class WebRTCCall { */ private async rotateSessionKey(): Promise { if (!this.sessionKey) return; - + try { // Generate new session key const newSessionKey = await rotateCallSessionKey(); - + // Update the call with new session key this.sessionKey = newSessionKey.key; - + // Reapply E2EE transforms with new key await this.applyE2EETransforms(); } catch (error) { @@ -645,9 +645,9 @@ export class WebRTCCall { if (!this.sessionKey || !window.RTCRtpScriptTransform) { return; } - + const key = await importAesGcmKey(this.sessionKey); - + // Apply to receivers that don't already have transforms const receivers = this.peerConnection.getReceivers(); for (const receiver of receivers) { @@ -656,7 +656,7 @@ export class WebRTCCall { this.transformedReceivers.add(receiver); } } - + // Apply to senders that don't already have transforms const senders = this.peerConnection.getSenders(); for (const sender of senders) { @@ -678,9 +678,9 @@ export class WebRTCCall { if (!sessionKey || !window.RTCRtpScriptTransform) { return; } - + const key = await importAesGcmKey(sessionKey); - + // Apply to receivers that don't already have transforms const receivers = this.peerConnection.getReceivers(); for (const receiver of receivers) { @@ -689,7 +689,7 @@ export class WebRTCCall { this.transformedReceivers.add(receiver); } } - + // Apply to senders that don't already have transforms const senders = this.peerConnection.getSenders(); for (const sender of senders) { @@ -735,7 +735,7 @@ export class WebRTCCall { if (this.keyRotationTimer) { clearInterval(this.keyRotationTimer); } - + // Close peer connection if (this.peerConnection) { this.peerConnection.close(); @@ -833,8 +833,8 @@ export async function initiateCall(userId: number, username: string): Promise { try { @@ -906,14 +906,14 @@ export async function receiveWrappedSessionKey( console.error("Missing wrapped payload or session key hash"); return; } - + // Unwrap the session key from the encrypted payload const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, { salt: wrappedPayload.salt, iv2: wrappedPayload.iv2, wrapped: wrappedPayload.wrapped }); - + // Use the unwrapped session key directly (both sides should have the same key) await setSessionKey(fromUserId, unwrappedSessionKey); } catch (e) { @@ -973,7 +973,7 @@ export async function endCall(userId: number): Promise { const call = calls.get(userId); if (call && !call.isEnding) { call.isEnding = true; - + // Send call end message await sendSignalingMessage({ type: "call_end", @@ -1009,7 +1009,7 @@ export async function onRemoteAccepted(userId: number): Promise { // Small delay to ensure remote peer finishes processing the accept // This prevents race conditions where our offer arrives before they're ready await delay(NEGOTIATION_DELAY); - + // Create offer const offer = await call.peerConnection.createOffer(); await call.peerConnection.setLocalDescription(offer); @@ -1029,7 +1029,7 @@ export async function onRemoteAccepted(userId: number): Promise { export async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit): Promise { let call = calls.get(userId); - + // Handle race condition - offer might arrive before peer connection is created if (!call) { call = await createPeerConnection(userId); @@ -1085,23 +1085,23 @@ export async function handleCallAnswer(userId: number, answer: RTCSessionDescrip try { await call.peerConnection.setRemoteDescription(answer); - + // Reset negotiating flag call.isNegotiating = false; - + // Attach transforms on initiator side if session key is available // If not available yet, setSessionKey will apply them when it arrives if (call.sessionKey) { await call.createE2EETransform(call.sessionKey, call.sessionId); } - + // Check if there are new receivers with tracks that haven't been notified yet // This handles the case where tracks exist but the track event hasn't fired yet const receivers = call.peerConnection.getReceivers(); for (const receiver of receivers) { if (receiver.track) { const track = receiver.track; - + // Find the stream for this track const transceiver = call.peerConnection.getTransceivers().find(t => t.receiver === receiver); if (transceiver && transceiver.receiver.track) { diff --git a/frontend/src/core/components/SearchBar.tsx b/frontend/src/core/components/SearchBar.tsx index b73593d..af8d919 100644 --- a/frontend/src/core/components/SearchBar.tsx +++ b/frontend/src/core/components/SearchBar.tsx @@ -12,7 +12,7 @@ interface SearchBarProps { rightIcon?: string | React.ReactNode; } -export default function SearchBar({ +export default function SearchBar({ placeholder, children, searchQuery, @@ -66,11 +66,11 @@ export default function SearchBar({ }; return ( -
-
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref }) { - return })} /> } \ No newline at end of file diff --git a/frontend/src/core/components/css/searchBar.scss b/frontend/src/core/components/css/searchBar.scss index ea84499..2d732e5 100644 --- a/frontend/src/core/components/css/searchBar.scss +++ b/frontend/src/core/components/css/searchBar.scss @@ -14,16 +14,16 @@ $font-size: 16px; position: absolute; z-index: 1001; overflow: hidden; - + // All properties animate together simultaneously - transition: + transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1), top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), right 0.4s cubic-bezier(0.4, 0, 0.2, 1), border-radius 0.4s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1); - + // Initial background color for smooth transition background-color: $color-dark-surface-container-high; @@ -143,7 +143,7 @@ $font-size: 16px; mdui-list-item { img[slot="icon"] { $size: 48px; - + width: $size; height: $size; border-radius: 50%; diff --git a/frontend/src/core/onlineStatusManager.ts b/frontend/src/core/onlineStatusManager.ts new file mode 100644 index 0000000..6f2525a --- /dev/null +++ b/frontend/src/core/onlineStatusManager.ts @@ -0,0 +1,152 @@ +/** + * @fileoverview Online status manager for real-time user status tracking + * @description Handles subscription to user online statuses via WebSocket + * @author Cursor + * @version 1.0.0 + */ + +import { request } from "./websocket"; +import type { + StatusUpdateWebSocketMessage, + SubscribeStatusWebSocketMessage, + UnsubscribeStatusWebSocketMessage +} from "./types"; +import { useAppState } from "@/pages/chat/state"; + +export interface UserStatus { + online: boolean; + lastSeen: string; +} + +/** + * Manages online status subscriptions and updates + */ +export class OnlineStatusManager { + private subscribedUsers: Set = new Set(); + private statusCache: Map = new Map(); + private authToken: string | null = null; + + /** + * Set the authentication token for WebSocket requests + */ + setAuthToken(token: string | null): void { + this.authToken = token; + } + + /** + * Subscribe to a user's online status + */ + async subscribe(userId: number): Promise { + if (!this.authToken || this.subscribedUsers.has(userId)) { + return; + } + + try { + const message: SubscribeStatusWebSocketMessage = { + type: "subscribeStatus", + credentials: { + scheme: "Bearer", + credentials: this.authToken + }, + data: { + userId + } + }; + + await request(message); + this.subscribedUsers.add(userId); + } catch (error) { + console.error(`Failed to subscribe to user ${userId} status:`, error); + } + } + + /** + * Unsubscribe from a user's online status + */ + async unsubscribe(userId: number): Promise { + if (!this.authToken || !this.subscribedUsers.has(userId)) { + return; + } + + try { + const message: UnsubscribeStatusWebSocketMessage = { + type: "unsubscribeStatus", + credentials: { + scheme: "Bearer", + credentials: this.authToken + }, + data: { + userId + } + }; + + await request(message); + this.subscribedUsers.delete(userId); + this.statusCache.delete(userId); + } catch (error) { + console.error(`Failed to unsubscribe from user ${userId} status:`, error); + } + } + + /** + * Handle incoming status update from WebSocket + */ + handleStatusUpdate(message: StatusUpdateWebSocketMessage): void { + const { userId, online, lastSeen } = message.data; + this.statusCache.set(userId, { online, lastSeen }); + + // Update the global state + const { updateOnlineStatus } = useAppState.getState(); + updateOnlineStatus(userId, online, lastSeen); + } + + /** + * Get cached status for a user + */ + getStatus(userId: number): UserStatus | undefined { + return this.statusCache.get(userId); + } + + /** + * Get all cached statuses + */ + getAllStatuses(): Map { + return new Map(this.statusCache); + } + + /** + * Check if subscribed to a user's status + */ + isSubscribed(userId: number): boolean { + return this.subscribedUsers.has(userId); + } + + /** + * Get all subscribed user IDs + */ + getSubscribedUsers(): Set { + return new Set(this.subscribedUsers); + } + + /** + * Unsubscribe from all users and clear cache + */ + async unsubscribeAll(): Promise { + const unsubscribePromises = Array.from(this.subscribedUsers).map(userId => + this.unsubscribe(userId) + ); + await Promise.all(unsubscribePromises); + this.subscribedUsers.clear(); + this.statusCache.clear(); + } + + /** + * Cleanup when component unmounts + */ + cleanup(): void { + this.unsubscribeAll(); + } +} + +// Global instance +export const onlineStatusManager = new OnlineStatusManager(); diff --git a/frontend/src/core/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts index 6c265f8..435ee27 100644 --- a/frontend/src/core/push-notifications/push-notifications.ts +++ b/frontend/src/core/push-notifications/push-notifications.ts @@ -108,8 +108,8 @@ async function showMessageNotification(message: any): Promise { try { await showNotification({ title: `New message from ${message.username}`, - body: message.content.length > 100 - ? message.content.substring(0, 100) + "..." + body: message.content.length > 100 + ? message.content.substring(0, 100) + "..." : message.content, icon: message.profile_picture || "/logo.png", tag: `message_${message.id}`, @@ -155,7 +155,7 @@ export async function initialize(): Promise { try { registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" }); console.log("Service Worker registered successfully"); - + const permission = await Notification.requestPermission(); if (permission === "granted") { await subscribeToWebPush(); @@ -200,7 +200,7 @@ export async function showNotification(payload: NotificationPayload): Promise { } isElectronReceiverRunning = true; - + // Add our own message listener to the existing WebSocket messageListener = (event: MessageEvent) => { try { @@ -250,7 +250,7 @@ export async function startElectronReceiver(): Promise { console.error('Failed to parse WebSocket message:', error); } }; - + websocket.addEventListener('message', messageListener); } @@ -260,7 +260,7 @@ export function stopElectronReceiver(): void { } isElectronReceiverRunning = false; - + // Remove our message listener if (messageListener) { websocket.removeEventListener('message', messageListener); diff --git a/frontend/src/core/push-notifications/service-worker.ts b/frontend/src/core/push-notifications/service-worker.ts index 6d48b01..ff40735 100644 --- a/frontend/src/core/push-notifications/service-worker.ts +++ b/frontend/src/core/push-notifications/service-worker.ts @@ -33,7 +33,7 @@ self.addEventListener("push", function(event: ExtendableEvent) { const pushEvent = event as PushEvent; if (pushEvent.data) { const data: NotificationPayload = pushEvent.data.json(); - + const options: NotificationOptions = { body: data.body, icon: data.icon || "/logo.png", diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 6b9382c..0c13a15 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -221,17 +221,17 @@ export interface DmFile { path: string; } -export interface DmEditedPayload { - id: number; - iv: string; - ciphertext: string; - timestamp: string +export interface DmEditedPayload { + id: number; + iv: string; + ciphertext: string; + timestamp: string } -export interface DmDeletedPayload { - id: number; - senderId: number; - recipientId: number +export interface DmDeletedPayload { + id: number; + senderId: number; + recipientId: number } export interface FetchDMResponse { @@ -461,7 +461,7 @@ export interface CallSignalingMessage extends WebSocketMessage { data: CallSignalingMessageData; } -export type CallSignalingMessageData = +export type CallSignalingMessageData = | CallInviteMessageData | CallAcceptData | CallRejectData @@ -530,4 +530,94 @@ export interface CallVideoToggleMessage extends CallSignalingMessage { export interface CallScreenShareToggleMessage extends CallSignalingMessage { type: "call_screen_share_toggle"; data: CallScreenShareToggleData; +} + +// ----------- +// Online Status & Typing WebSocket Messages +// ----------- + +export interface StatusUpdateWebSocketMessage extends WebSocketMessage { + type: "statusUpdate"; + data: { + userId: number; + online: boolean; + lastSeen: string; + }; +} + +export interface SubscribeStatusWebSocketMessage extends WebSocketMessage { + type: "subscribeStatus"; + credentials: WebSocketCredentials; + data: { + userId: number; + }; +} + +export interface UnsubscribeStatusWebSocketMessage extends WebSocketMessage { + type: "unsubscribeStatus"; + credentials: WebSocketCredentials; + data: { + userId: number; + }; +} + +export interface TypingWebSocketMessage extends WebSocketMessage { + type: "typing"; + data: { + userId: number; + username: string; + }; +} + +export interface StopTypingWebSocketMessage extends WebSocketMessage { + type: "stopTyping"; + data: { + userId: number; + username: string; + }; +} + +export interface DmTypingWebSocketMessage extends WebSocketMessage { + type: "dmTyping"; + data: { + userId: number; + username: string; + }; +} + +export interface StopDmTypingWebSocketMessage extends WebSocketMessage { + type: "stopDmTyping"; + data: { + userId: number; + username: string; + }; +} + +// Request types for sending typing/status messages +export interface TypingRequest extends WebSocketMessage { + type: "typing"; + credentials: WebSocketCredentials; + data: {}; +} + +export interface StopTypingRequest extends WebSocketMessage { + type: "stopTyping"; + credentials: WebSocketCredentials; + data: {}; +} + +export interface DmTypingRequest extends WebSocketMessage { + type: "dmTyping"; + credentials: WebSocketCredentials; + data: { + recipientId: number; + }; +} + +export interface StopDmTypingRequest extends WebSocketMessage { + type: "stopDmTyping"; + credentials: WebSocketCredentials; + data: { + recipientId: number; + }; } \ No newline at end of file diff --git a/frontend/src/core/typingManager.ts b/frontend/src/core/typingManager.ts new file mode 100644 index 0000000..da5b119 --- /dev/null +++ b/frontend/src/core/typingManager.ts @@ -0,0 +1,232 @@ +/** + * @fileoverview Typing indicator manager for real-time typing status + * @description Handles typing indicators for public chat and DMs via WebSocket + * @author Cursor + * @version 1.0.0 + */ + +import { request } from "./websocket"; +import type { + TypingWebSocketMessage, + StopTypingWebSocketMessage, + DmTypingWebSocketMessage, + StopDmTypingWebSocketMessage, + TypingRequest, + StopTypingRequest, + DmTypingRequest, + StopDmTypingRequest +} from "./types"; +import { useAppState } from "@/pages/chat/state"; + +/** + * Manages typing indicators for public chat and DMs + */ +export class TypingManager { + private authToken: string | null = null; + private typingTimeouts: Map = new Map(); + private readonly TYPING_TIMEOUT = 3000; // 3 seconds + + /** + * Set the authentication token for WebSocket requests + */ + setAuthToken(token: string | null): void { + this.authToken = token; + } + + /** + * Send typing indicator for public chat + */ + async sendTyping(): Promise { + if (!this.authToken) return; + + try { + const message: TypingRequest = { + type: "typing", + credentials: { + scheme: "Bearer", + credentials: this.authToken + }, + data: {} + }; + + await request(message); + this.scheduleStopTyping("public"); + } catch (error) { + console.error("Failed to send typing indicator:", error); + } + } + + /** + * Send stop typing indicator for public chat + */ + async sendStopTyping(): Promise { + if (!this.authToken) return; + + try { + const message: StopTypingRequest = { + type: "stopTyping", + credentials: { + scheme: "Bearer", + credentials: this.authToken + }, + data: {} + }; + + await request(message); + this.clearStopTypingTimeout("public"); + } catch (error) { + console.error("Failed to send stop typing indicator:", error); + } + } + + /** + * Send typing indicator for DM + */ + async sendDmTyping(recipientId: number): Promise { + if (!this.authToken) return; + + try { + const message: DmTypingRequest = { + type: "dmTyping", + credentials: { + scheme: "Bearer", + credentials: this.authToken + }, + data: { + recipientId + } + }; + + await request(message); + this.scheduleStopDmTyping(recipientId); + } catch (error) { + console.error("Failed to send DM typing indicator:", error); + } + } + + /** + * Send stop typing indicator for DM + */ + async sendStopDmTyping(recipientId: number): Promise { + if (!this.authToken) return; + + try { + const message: StopDmTypingRequest = { + type: "stopDmTyping", + credentials: { + scheme: "Bearer", + credentials: this.authToken + }, + data: { + recipientId + } + }; + + await request(message); + this.clearStopTypingTimeout(`dm_${recipientId}`); + } catch (error) { + console.error("Failed to send stop DM typing indicator:", error); + } + } + + /** + * Handle incoming typing indicator from WebSocket + */ + handleTyping(message: TypingWebSocketMessage): void { + const { addTypingUser } = useAppState.getState(); + addTypingUser(message.data.userId, message.data.username); + } + + /** + * Handle incoming stop typing indicator from WebSocket + */ + handleStopTyping(message: StopTypingWebSocketMessage): void { + const { removeTypingUser } = useAppState.getState(); + removeTypingUser(message.data.userId); + } + + /** + * Handle incoming DM typing indicator from WebSocket + */ + handleDmTyping(message: DmTypingWebSocketMessage): void { + const { setDmTypingUser } = useAppState.getState(); + setDmTypingUser(message.data.userId, true); + } + + /** + * Handle incoming stop DM typing indicator from WebSocket + */ + handleStopDmTyping(message: StopDmTypingWebSocketMessage): void { + const { setDmTypingUser } = useAppState.getState(); + setDmTypingUser(message.data.userId, false); + } + + /** + * Schedule automatic stop typing after timeout + */ + private scheduleStopTyping(context: string): void { + this.clearStopTypingTimeout(context); + + const timeout = setTimeout(async () => { + if (context === "public") { + await this.sendStopTyping(); + } + this.typingTimeouts.delete(context); + }, this.TYPING_TIMEOUT); + + this.typingTimeouts.set(context, timeout); + } + + /** + * Schedule automatic stop DM typing after timeout + */ + private scheduleStopDmTyping(recipientId: number): void { + const context = `dm_${recipientId}`; + this.clearStopTypingTimeout(context); + + const timeout = setTimeout(async () => { + await this.sendStopDmTyping(recipientId); + this.typingTimeouts.delete(context); + }, this.TYPING_TIMEOUT); + + this.typingTimeouts.set(context, timeout); + } + + /** + * Clear stop typing timeout + */ + private clearStopTypingTimeout(context: string): void { + const timeout = this.typingTimeouts.get(context); + if (timeout) { + clearTimeout(timeout); + this.typingTimeouts.delete(context); + } + } + + /** + * Immediately stop typing for public chat (called when message is sent) + */ + async stopTypingOnMessage(): Promise { + this.clearStopTypingTimeout("public"); + await this.sendStopTyping(); + } + + /** + * Immediately stop DM typing (called when message is sent) + */ + async stopDmTypingOnMessage(recipientId: number): Promise { + this.clearStopTypingTimeout(`dm_${recipientId}`); + await this.sendStopDmTyping(recipientId); + } + + /** + * Cleanup all timeouts + */ + cleanup(): void { + this.typingTimeouts.forEach(timeout => clearTimeout(timeout)); + this.typingTimeouts.clear(); + } +} + +// Global instance +export const typingManager = new TypingManager(); diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index 1a6d736..f205de1 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -9,6 +9,8 @@ import { API_WS_BASE_URL } from "./config"; import type { WebSocketMessage } from "./types"; import { delay } from "@/utils/utils"; import { CallSignalingHandler } from "./calls/signaling"; +import { onlineStatusManager } from "./onlineStatusManager"; +import { typingManager } from "./typingManager"; /** * Creates a new WebSocket connection to the chat server @@ -85,7 +87,7 @@ export function request(payload: WebSocketMessage { try { const response: WebSocketMessage = JSON.parse(e.data); - + // Handle call signaling messages if (callSignalingHandler && response.type === "call_signaling" && response.data) { callSignalingHandler.handleWebSocketMessage(response.data); } - + + // Handle status and typing messages + if (response.type === "statusUpdate") { + onlineStatusManager.handleStatusUpdate(response as any); + } else if (response.type === "typing") { + typingManager.handleTyping(response as any); + } else if (response.type === "stopTyping") { + typingManager.handleStopTyping(response as any); + } else if (response.type === "dmTyping") { + typingManager.handleDmTyping(response as any); + } else if (response.type === "stopDmTyping") { + typingManager.handleStopDmTyping(response as any); + } + // Route message to global handler if set if (globalMessageHandler) { globalMessageHandler(response); diff --git a/frontend/src/css/_animations.scss b/frontend/src/css/_animations.scss index d7711e2..e0a245f 100644 --- a/frontend/src/css/_animations.scss +++ b/frontend/src/css/_animations.scss @@ -1,15 +1,15 @@ @keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); + from { + opacity: 0; + transform: translateY(10px); } - - to { - opacity: 1; - transform: translateY(0); + + to { + opacity: 1; + transform: translateY(0); } } - + .fade-in { animation: fadeIn 0.3s ease forwards; } diff --git a/frontend/src/css/_components.scss b/frontend/src/css/_components.scss index 03bb64c..a8c64b1 100644 --- a/frontend/src/css/_components.scss +++ b/frontend/src/css/_components.scss @@ -14,7 +14,7 @@ background-color: #C6F6D5; color: #22543D; } - + &.alert-danger { background-color: #FED7D7; color: #742A2A; @@ -22,7 +22,7 @@ } .link { - color: $color-dark-primary; + color: $color-dark-primary; font-weight: 600; } @@ -38,11 +38,11 @@ button, input { font-size: 1.2rem; font-weight: 600; } - + mdui-text-field { width: 100%; } - + .dialog-actions { display: flex; gap: 0.75rem; diff --git a/frontend/src/css/_material.scss b/frontend/src/css/_material.scss index 5317104..ae8241c 100644 --- a/frontend/src/css/_material.scss +++ b/frontend/src/css/_material.scss @@ -54,10 +54,10 @@ $color-dark-surface-primary-container-lightened: color.adjust($color-dark-primar $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); +$color-1: rgb(82, 109, 246); +$color-2: rgb(65, 11, 113); +$color-4: rgb(95, 26, 198); +$color-3: rgb(49, 71, 179); // Light $color-light-primary: rgb(31 101 134); $color-light-surface-tint: rgb(31 101 134); diff --git a/frontend/src/pages/ProtectedRoute.tsx b/frontend/src/pages/ProtectedRoute.tsx index 27fac5e..dec0ff5 100644 --- a/frontend/src/pages/ProtectedRoute.tsx +++ b/frontend/src/pages/ProtectedRoute.tsx @@ -9,13 +9,13 @@ interface ProtectedRouteProps { export default function ProtectedRoute({ children }: ProtectedRouteProps) { const { user } = useAppState(); const navigate = useNavigate(); - + useEffect(() => { if (!user.authToken) { navigate("/login"); return; } }, [user.authToken, user.currentUser, navigate]); - + return <>{children}; } diff --git a/frontend/src/pages/auth/Auth.tsx b/frontend/src/pages/auth/Auth.tsx index 5c6b48a..65c30b5 100644 --- a/frontend/src/pages/auth/Auth.tsx +++ b/frontend/src/pages/auth/Auth.tsx @@ -30,7 +30,7 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) { return (

- {iconName} + {iconName} {title}

{subtitle}

diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index 3904627..0cec4c5 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -33,19 +33,19 @@ export default function LoginPage() {
- +
{ e.preventDefault(); - + const username = usernameElement.current!.value.trim(); const password = passwordElement.current!.value.trim(); - + if (!username || !password) { showAlert("danger", "Пожалуйста, заполните все поля"); return; } - + try { const request: LoginRequest = { username: username, @@ -59,12 +59,12 @@ export default function LoginPage() { }, 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); @@ -73,19 +73,19 @@ export default function LoginPage() { } 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"); @@ -113,7 +113,7 @@ export default function LoginPage() { autocomplete="username" required ref={usernameElement} /> - + Войти - +

- Ещё нет аккаунта? + Ещё нет аккаунта? navigate("/register")}> Зарегистрируйтесь diff --git a/frontend/src/pages/auth/RegisterPage.tsx b/frontend/src/pages/auth/RegisterPage.tsx index e4e484d..dfc10b8 100644 --- a/frontend/src/pages/auth/RegisterPage.tsx +++ b/frontend/src/pages/auth/RegisterPage.tsx @@ -32,41 +32,41 @@ export default function RegisterPage() {

- +
{ e.preventDefault(); - + const username = usernameElement.current!.value.trim(); const password = passwordElement.current!.value.trim(); const confirmPassword = confirmPasswordElement.current!.value.trim(); - + if (!username || !password || !confirmPassword) { showAlert("danger", "Пожалуйста, заполните все поля"); return; } - + if (password !== confirmPassword) { showAlert("danger", "Пароли не совпадают"); return; } - + if (username.length < 3 || username.length > 20) { showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов"); return; } - + if (password.length < 5 || password.length > 50) { showAlert("danger", "Пароль должен быть от 5 до 50 символов"); return; } - + try { const request: RegisterRequest = { username: username, password: password, confirm_password: confirmPassword } - + const response = await fetch(`${API_BASE_URL}/register`, { method: 'POST', headers: { @@ -74,12 +74,12 @@ export default function RegisterPage() { }, 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); @@ -97,9 +97,9 @@ export default function RegisterPage() { } }}> Зарегистрироваться - +

- Уже есть аккаунт? - navigate("/login")}> Войдите diff --git a/frontend/src/pages/auth/auth.scss b/frontend/src/pages/auth/auth.scss index 2427d3c..78946e8 100644 --- a/frontend/src/pages/auth/auth.scss +++ b/frontend/src/pages/auth/auth.scss @@ -18,7 +18,7 @@ max-width: 450px; overflow: hidden; } - + .auth-header { margin: 0; padding: 16px; @@ -36,7 +36,7 @@ justify-content: center; } } - + .auth-body { padding: 25px; padding-bottom: 16px; diff --git a/frontend/src/pages/chat/css/_callWindow.scss b/frontend/src/pages/chat/css/_callWindow.scss index b54e2ab..8541476 100644 --- a/frontend/src/pages/chat/css/_callWindow.scss +++ b/frontend/src/pages/chat/css/_callWindow.scss @@ -9,10 +9,10 @@ display: flex; flex-direction: column; user-select: none; - + // Base transition for all properties transition: all 0.4s $transition; - + // Disable all transitions while dragging for immediate feedback &.dragging { transition: none !important; @@ -100,7 +100,7 @@ .call-header { padding: 12px; min-height: auto; - + .call-header-info { .username { font-size: 14px; @@ -170,7 +170,7 @@ // Dynamic gradients based on call state &.gradient-calling { border-color: rgba(255, 193, 7, 0.5); - + &::before { content: ''; position: absolute; @@ -178,9 +178,9 @@ left: 0; right: 0; bottom: 0; - background: linear-gradient(135deg, - rgba(255, 193, 7, 0.12) 0%, - rgba(255, 152, 0, 0.12) 50%, + background: linear-gradient(135deg, + rgba(255, 193, 7, 0.12) 0%, + rgba(255, 152, 0, 0.12) 50%, rgba(255, 193, 7, 0.12) 100%); border-radius: inherit; animation: pulse-gradient 2s ease-in-out infinite; @@ -190,7 +190,7 @@ &.gradient-connecting { border-color: rgba(33, 150, 243, 0.5); - + &::before { content: ''; position: absolute; @@ -198,9 +198,9 @@ left: 0; right: 0; bottom: 0; - background: linear-gradient(135deg, - rgba(33, 150, 243, 0.12) 0%, - rgba(63, 81, 181, 0.12) 50%, + background: linear-gradient(135deg, + rgba(33, 150, 243, 0.12) 0%, + rgba(63, 81, 181, 0.12) 50%, rgba(33, 150, 243, 0.12) 100%); border-radius: inherit; animation: connecting-gradient 1.5s ease-in-out infinite; @@ -210,7 +210,7 @@ &.gradient-active { border-color: rgba(76, 175, 80, 0.5); - + &::before { content: ''; position: absolute; @@ -218,9 +218,9 @@ left: 0; right: 0; bottom: 0; - background: linear-gradient(135deg, - rgba(76, 175, 80, 0.12) 0%, - rgba(56, 142, 60, 0.12) 50%, + background: linear-gradient(135deg, + rgba(76, 175, 80, 0.12) 0%, + rgba(56, 142, 60, 0.12) 50%, rgba(76, 175, 80, 0.12) 100%); border-radius: inherit; animation: active-gradient 3s ease-in-out infinite; @@ -288,7 +288,7 @@ font-size: 24px; display: inline-block; animation: emoji-pulse 2s ease-in-out infinite; - + &:nth-child(1) { animation-delay: 0s; } &:nth-child(2) { animation-delay: 0.2s; } &:nth-child(3) { animation-delay: 0.4s; } @@ -355,7 +355,7 @@ overflow-y: auto; overflow-x: hidden; padding: 5px; - + // Custom scrollbar &::-webkit-scrollbar { width: 6px; @@ -369,7 +369,7 @@ &::-webkit-scrollbar-thumb { background: rgba($color-dark-primary, 0.5); border-radius: 3px; - + &:hover { background: rgba($color-dark-primary, 0.7); } diff --git a/frontend/src/pages/chat/css/_chat-input.scss b/frontend/src/pages/chat/css/_chat-input.scss index 9ac5779..717ca4f 100644 --- a/frontend/src/pages/chat/css/_chat-input.scss +++ b/frontend/src/pages/chat/css/_chat-input.scss @@ -19,12 +19,12 @@ display: flex; align-items: flex-start; gap: 16px; - + mdui-icon { align-self: center; box-sizing: content-box; } - + .reply-cancel { margin-left: auto; } @@ -45,13 +45,13 @@ display: flex; flex-direction: row; align-items: center; - + .buttons, .left-buttons { display: flex; flex-direction: row; align-items: center; } - + .left-buttons { .emoji-btn { margin: 10px; @@ -59,13 +59,13 @@ transition: color 0.2s ease; flex-shrink: 0; align-self: flex-end; - + &:hover { color: $color-dark-primary; } } } - + .message-input { flex: 1; padding: 20px 0; @@ -81,7 +81,7 @@ font-size: 13pt; height: 100%; width: 100%; - + &::placeholder { color: $color-dark-on-surface-variant; opacity: 0.7; @@ -104,7 +104,7 @@ transition: all 0.25s ease; align-self: flex-end; box-shadow: 0 0 20px rgba($color-dark-primary, 0.4); - + &:hover { transform: translateY(-2px); box-shadow: 0 0 30px rgba($color-dark-primary, 0.6); @@ -115,6 +115,8 @@ } } +// Typing indicator styles + // Emoji Menu Styles .emoji-menu { $transition: cubic-bezier(0.4, 0, 0.2, 1); @@ -154,7 +156,7 @@ overflow-y: hidden; scroll-behavior: smooth; padding: 8px; - + &::-webkit-scrollbar { height: 4px; } @@ -229,7 +231,7 @@ flex: 1; overflow-y: auto; scroll-behavior: smooth; - + &::-webkit-scrollbar { width: 6px; } diff --git a/frontend/src/pages/chat/css/_context-menu.scss b/frontend/src/pages/chat/css/_context-menu.scss index 9e2e1bb..1648c43 100644 --- a/frontend/src/pages/chat/css/_context-menu.scss +++ b/frontend/src/pages/chat/css/_context-menu.scss @@ -12,7 +12,7 @@ transition: all 0.15s ease; backdrop-filter: blur(8px); transform: translateY(0); - + &.closing { opacity: 0; transform: scale(0.8); @@ -32,50 +32,50 @@ .context-menu-wrapper { position: relative; display: block; - + // Animation states &.entering { opacity: 0; transform: scale(0.8); animation: contextMenuEnter 0.2s ease forwards; } - + &.entering-left { opacity: 0; transform: translateX(-20px) scale(0.8); animation: contextMenuEnterLeft 0.2s ease forwards; } - + &.entering-up { opacity: 0; transform: translateY(20px) scale(0.8); animation: contextMenuEnterUp 0.2s ease forwards; } - + &.entering-up-left { opacity: 0; transform: translateX(-20px) translateY(20px) scale(0.8); animation: contextMenuEnterUpLeft 0.2s ease forwards; } - + &.closing { opacity: 1; transform: scale(1); animation: contextMenuClose 0.2s ease forwards; } - + &.closing-left { opacity: 1; transform: translateX(0) scale(1); animation: contextMenuCloseLeft 0.2s ease forwards; } - + &.closing-up { opacity: 1; transform: translateY(0) scale(1); animation: contextMenuCloseUp 0.2s ease forwards; } - + &.closing-up-left { opacity: 1; transform: translateX(0) translateY(0) scale(1); @@ -90,39 +90,39 @@ padding: 0.5rem 0; min-width: 160px; z-index: 1000; - + &.entering { animation: fadeInDown 0.2s ease forwards; } - + &.entering-left { animation: fadeInLeft 0.2s ease forwards; } - + &.entering-up { animation: fadeInUp 0.2s ease forwards; } - + &.entering-up-left { animation: fadeInUpLeft 0.2s ease forwards; } - + &.closing { animation: fadeOutUp 0.2s ease forwards; } - + &.closing-left { animation: fadeOutRight 0.2s ease forwards; } - + &.closing-up { animation: fadeOutDown 0.2s ease forwards; } - + &.closing-up-left { animation: fadeOutDownRight 0.2s ease forwards; } - + .context-menu-item { display: flex; align-items: center; @@ -132,11 +132,11 @@ color: $color-dark-on-surface; font-size: 0.9rem; transition: background-color 0.2s ease; - + &:hover { background-color: $color-dark-surface-container; } - + .material-symbols { font-size: 1.1rem; color: $color-dark-on-surface-variant; @@ -158,31 +158,31 @@ justify-content: center; margin-bottom: 10px; transition: width 0.3s ease-out, height 0.3s ease-out; - + &.left { left: 0; transform: translateX(0); } - + &.right { right: 0; transform: translateX(0); } - + &.expanded { padding: 0; overflow: hidden; width: 320px; height: 400px; border-radius: 16px; - + // Default: expand downward from the reaction bar's bottom edge position: absolute; bottom: auto; top: 0; left: 0; transform: translateY(0); - + &.expand-upward { // Expand upward from the reaction bar's top edge bottom: 100%; @@ -203,7 +203,7 @@ align-items: center; gap: 4px; transition: opacity 0.3s ease-out; - + &.faded { opacity: 0; } @@ -221,13 +221,13 @@ cursor: pointer; transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); font-size: 18px; - + &:hover { background: var(--mdui-color-surface-container-high); transform: scale(1.3); box-shadow: var(--mdui-elevation-1); } - + &:active { transform: scale(0.95); transition: transform 0.1s ease; @@ -245,25 +245,25 @@ background: var(--mdui-color-surface); cursor: pointer; transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); - + &:hover { background: var(--mdui-color-surface-container-high); border-color: var(--mdui-color-primary); transform: scale(1.1); box-shadow: var(--mdui-elevation-1); } - + &:active { transform: scale(0.95); transition: transform 0.1s ease; } - + .material-symbols { font-size: 18px; color: var(--mdui-color-on-surface); transition: transform 0.2s ease; } - + &:hover .material-symbols { transform: rotate(90deg); } diff --git a/frontend/src/pages/chat/css/_layout.scss b/frontend/src/pages/chat/css/_layout.scss index b64bea8..561eb7f 100644 --- a/frontend/src/pages/chat/css/_layout.scss +++ b/frontend/src/pages/chat/css/_layout.scss @@ -6,7 +6,7 @@ height: 100%; background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 70%, rgba($color-dark-primary-container, 0.3) 100%); position: relative; - + &::before { content: ''; position: fixed; @@ -14,7 +14,7 @@ left: 0; right: 0; bottom: 0; - background: + background: radial-gradient(circle at 20% 80%, rgba($color-dark-primary, 0.15) 0%, transparent 50%), 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%); diff --git a/frontend/src/pages/chat/css/_left-panel.scss b/frontend/src/pages/chat/css/_left-panel.scss index 4f4eced..06b5ab8 100644 --- a/frontend/src/pages/chat/css/_left-panel.scss +++ b/frontend/src/pages/chat/css/_left-panel.scss @@ -111,7 +111,7 @@ .product-name { flex-grow: 1; } - + .profile { font-size: 24px; display: flex; @@ -125,7 +125,7 @@ border: solid 2px $color-dark-on-surface-variant; padding: 5px; border-radius: 10px; - + &:hover { background-color: rgba(255, 255, 255, 0.241); } @@ -156,7 +156,6 @@ height: 45px; border-radius: 20%; object-fit: cover; - margin-right: 1rem; } mdui-tabs { @@ -210,18 +209,18 @@ background-clip: text; text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); } - + .profile { a { display: flex; align-items: center; text-decoration: none; transition: transform 0.3s ease; - + &:hover { transform: scale(1.05); } - + img { width: 40px; height: 40px; @@ -230,7 +229,7 @@ border: 2px solid rgba($color-dark-primary, 0.4); box-shadow: 0 0 15px rgba($color-dark-primary, 0.3); transition: all 0.3s ease; - + &:hover { box-shadow: 0 0 25px rgba($color-dark-primary, 0.5); border-color: rgba($color-dark-primary, 0.6); diff --git a/frontend/src/pages/chat/css/_message-reactions.scss b/frontend/src/pages/chat/css/_message-reactions.scss index 7225cc2..35aaa51 100644 --- a/frontend/src/pages/chat/css/_message-reactions.scss +++ b/frontend/src/pages/chat/css/_message-reactions.scss @@ -26,21 +26,21 @@ font-size: 1px; min-height: 28px; animation: reactionFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); - + &.removing { animation: reactionFadeOut 0.2s ease forwards; } - + &:hover { background-color: $color-dark-surface-container-high; transform: scale(1.05); } - + &.reacted { background-color: $color-dark-primary-container; border-color: $color-dark-primary; color: $color-dark-on-primary-container; - + &:hover { background-color: color.adjust($color-dark-primary-container, $lightness: 20%); } diff --git a/frontend/src/pages/chat/css/_message.scss b/frontend/src/pages/chat/css/_message.scss index 5a2fb0d..15e0069 100644 --- a/frontend/src/pages/chat/css/_message.scss +++ b/frontend/src/pages/chat/css/_message.scss @@ -37,11 +37,11 @@ flex-shrink: 0; cursor: pointer; transition: transform 0.2s ease; - + &:hover { transform: scale(1.05); } - + img { width: 100%; height: 100%; @@ -69,7 +69,7 @@ margin: 10px; cursor: pointer; transition: transform 0.2s ease; - + &:hover { transform: scale(1.05); } @@ -102,7 +102,7 @@ a { text-decoration: none; } - + .attachement-image { max-width: 200px; border-radius: 8px; @@ -199,7 +199,7 @@ border: 1px solid rgba($color-dark-outline-variant, 0.4); position: relative; overflow: hidden; - + &::before { content: ''; position: absolute; @@ -211,13 +211,13 @@ pointer-events: none; z-index: 0; } - + > * { position: relative; z-index: 1; } } - + .message-time { color: $color-dark-on-surface-variant; font-weight: 500; @@ -236,7 +236,7 @@ border: 1px solid rgba($color-dark-primary, 0.5); position: relative; overflow: hidden; - + &::before { content: ''; position: absolute; @@ -248,7 +248,7 @@ pointer-events: none; z-index: 0; } - + > * { position: relative; z-index: 1; diff --git a/frontend/src/pages/chat/css/_profile-dialog.scss b/frontend/src/pages/chat/css/_profile-dialog.scss index c216d93..a851eb1 100644 --- a/frontend/src/pages/chat/css/_profile-dialog.scss +++ b/frontend/src/pages/chat/css/_profile-dialog.scss @@ -20,7 +20,7 @@ opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease; - + &.open { opacity: 1; visibility: visible; @@ -42,26 +42,26 @@ transform: scale(0.9); opacity: 0; transition: transform 0.3s ease, opacity 0.3s ease; - + &.open { transform: scale(1); opacity: 1; } - + .profile-dialog-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; align-items: center; - + .profile-picture-section { position: relative; display: flex; justify-content: center; align-items: center; margin: 16px; - + .profile-picture { width: 120px; height: 120px; @@ -69,7 +69,7 @@ object-fit: cover; border: 3px solid $color-dark-outline; } - + .profile-picture-edit-overlay { position: absolute; top: 0; @@ -84,16 +84,16 @@ opacity: 0; transition: opacity 0.2s ease; cursor: pointer; - + &:hover { opacity: 1; } } } - + .username-section { text-align: center; - + .username-input { background: none; border: none; @@ -114,24 +114,24 @@ align-items: center; justify-content: center; gap: 8px; - + .online-indicator { width: 8px; height: 8px; border-radius: 50%; background: $color-dark-primary; - + &.offline { background: $color-dark-on-surface-variant; } } - + .status-text { font-size: 0.875rem; color: $color-dark-on-surface; } } - + .profile-sections { margin: 16px; border-radius: 24px; @@ -161,7 +161,7 @@ font-size: small; color: $color-dark-on-surface-variant; } - + .value { color: $color-dark-on-surface; font-size: medium; @@ -182,7 +182,7 @@ } } } - + .profile-dialog-fab { position: absolute; bottom: 24px; @@ -190,7 +190,7 @@ z-index: 1002; transform: translateY(100px); transition: transform 0.3s ease; - + &.visible { transform: translateY(0); } diff --git a/frontend/src/pages/chat/css/_right-panel.scss b/frontend/src/pages/chat/css/_right-panel.scss index 3777b8b..b7fa2a3 100644 --- a/frontend/src/pages/chat/css/_right-panel.scss +++ b/frontend/src/pages/chat/css/_right-panel.scss @@ -43,15 +43,6 @@ } } - .online-status { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - background-color: $success; - margin-right: 5px; - } - a { display: flex; flex-direction: row; @@ -98,11 +89,11 @@ left: 0; width: 100%; height: 100%; - + background: rgba(0, 0, 0, 0.5); - + z-index: 100; - + backdrop-filter: blur(20px); .file-overlay-wrapper { diff --git a/frontend/src/pages/chat/css/_settings-dialog.scss b/frontend/src/pages/chat/css/_settings-dialog.scss index fca04b6..679e967 100644 --- a/frontend/src/pages/chat/css/_settings-dialog.scss +++ b/frontend/src/pages/chat/css/_settings-dialog.scss @@ -40,7 +40,7 @@ flex: 1; overflow-y: auto; position: relative; - + .settings-panel { display: flex; flex-direction: column; @@ -53,43 +53,43 @@ top: 0; left: 0; width: 100%; - + &.active { opacity: 1; visibility: visible; transform: translateY(0); position: relative; } - + h3 { margin: 0 0 16px 0; color: $color-dark-on-surface; } - + mdui-text-field, mdui-select, mdui-switch, mdui-button { margin-bottom: 8px; } - + mdui-switch { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid $color-dark-outline; - + &:last-child { border-bottom: none; } } - + p { margin: 8px 0; color: $color-dark-on-surface-variant; } - + mdui-linear-progress { margin: 16px 0; } diff --git a/frontend/src/pages/chat/css/_typing-indicators.scss b/frontend/src/pages/chat/css/_typing-indicators.scss new file mode 100644 index 0000000..ddc2850 --- /dev/null +++ b/frontend/src/pages/chat/css/_typing-indicators.scss @@ -0,0 +1,109 @@ +@use "../../../css/colors" as *; +@use "../../../css/material" as *; +@use "sass:color"; + +// Unified typing indicator styles (used for both public chat and DMs) +.typing-indicator { + display: flex; + align-items: center; + gap: 8px; + color: $color-dark-primary; + + .typing-dots { + display: flex; + gap: 2px; + + span { + width: 4px; + height: 4px; + border-radius: 50%; + background: $color-dark-primary; + animation: typing-dot 1.4s infinite ease-in-out; + + &:nth-child(1) { + animation-delay: -0.32s; + } + + &:nth-child(2) { + animation-delay: -0.16s; + } + } + } + + .typing-text { + font-size: 0.875rem; + font-weight: 500; + } +} + +// Online status display (used in DMs when not typing) +.online-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + color: $color-dark-on-surface-variant; + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &.online { + background: #4caf50; + box-shadow: 0 0 6px rgba(76, 175, 80, 0.4); + } + + &.offline { + background: $color-dark-on-surface-variant; + opacity: 0.6; + } + } + + .status-text { + font-weight: 500; + font-size: 0.75rem; + opacity: 0.8; + } +} + +// Online indicator for profile pictures (positioned at bottom right) +.online-indicator { + position: absolute; + bottom: 0px; + right: 0px; + z-index: 10; + pointer-events: none; + transform: none; + + .indicator-dot { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid $color-dark-surface; + box-sizing: border-box; + display: block; + background: #4caf50; + position: relative; + transform: none; + } +} + +// Ensure the icon container allows absolute positioning +mdui-list-item [slot="icon"] { + position: relative; + display: inline-block; +} + +// Typing dot animation +@keyframes typing-dot { + 0%, 80%, 100% { + transform: scale(0.8); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } +} diff --git a/frontend/src/pages/chat/css/chat.scss b/frontend/src/pages/chat/css/chat.scss index 584ac27..6709f6b 100644 --- a/frontend/src/pages/chat/css/chat.scss +++ b/frontend/src/pages/chat/css/chat.scss @@ -9,4 +9,5 @@ @use "settings-dialog"; @use "animations"; @use "callWindow"; -@use "profile-dialog"; \ No newline at end of file +@use "profile-dialog"; +@use "typing-indicators"; \ No newline at end of file diff --git a/frontend/src/pages/chat/hooks/useCall.ts b/frontend/src/pages/chat/hooks/useCall.ts index bf5e611..71e14c8 100644 --- a/frontend/src/pages/chat/hooks/useCall.ts +++ b/frontend/src/pages/chat/hooks/useCall.ts @@ -14,21 +14,21 @@ let globalLocalScreenShareRef = createRef(); let globalRemoteScreenShareRef = createRef(); export default function useCall() { - const { - chat, - startCall, - endCall, + const { + chat, + startCall, + endCall, setCallStatus, - toggleMute, - toggleVideo, + toggleMute, + toggleVideo, toggleScreenShare, setCallEncryption, - setCallSessionKeyHash, + setCallSessionKeyHash, setRemoteVideoEnabled, setRemoteScreenSharing, - user + user } = useAppState(); - + const remoteAudioRef = globalRemoteAudioRef; const localVideoRef = globalLocalVideoRef; const remoteVideoRef = globalRemoteVideoRef; @@ -37,12 +37,12 @@ export default function useCall() { useEffect(() => { // Initialize call signaling handler - const signalingHandler = new CallSignalingHandler(() => ({ + const signalingHandler = new CallSignalingHandler(() => ({ receiveCall: (userId: number, username: string) => { // Use the receiveCall function from state const state = useAppState.getState(); state.receiveCall(userId, username); - }, + }, endCall, setCallSessionKeyHash, setRemoteVideoEnabled, @@ -195,11 +195,11 @@ export default function useCall() { async function requestAudioPermissions(): Promise { try { - const stream = await navigator.mediaDevices.getUserMedia({ + const stream = await navigator.mediaDevices.getUserMedia({ audio: true, - video: false + video: false }); - + // Stop the stream immediately as we just needed permission stream.getTracks().forEach(track => track.stop()); return true; @@ -211,7 +211,7 @@ export default function useCall() { async function initiateCall(userId: number, username: string) { const hasPermission = await requestAudioPermissions(); - + if (!hasPermission) { return; } @@ -221,7 +221,7 @@ export default function useCall() { // Generate call session key and emojis sessionKey = await generateCallSessionKey(); const emojis = generateCallEmojis(sessionKey.hash); - + // Start the call in state startCall(userId, username); setCallStatus("calling"); @@ -234,11 +234,11 @@ export default function useCall() { // Initiate WebRTC call const success = await WebRTC.initiateCall(userId, username); - + if (success && sessionKey) { // Set the session key for ourselves (initiator) await WebRTC.setSessionKey(userId, sessionKey.key); - + // Send session key hash to the receiver for visual verification await WebRTC.sendCallSessionKey(userId, sessionKey.hash); // Also wrap and send the actual session key for E2EE media @@ -255,7 +255,7 @@ export default function useCall() { setCallStatus("connecting"); const success = await WebRTC.acceptCall(chat.call.remoteUserId); - + if (!success) { endCall(); } diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 9e4b36d..fbdf108 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -1,9 +1,9 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useAppState } from "@/pages/chat/state"; -import { - fetchUserPublicKey, - fetchDMHistory, - decryptDm, +import { + fetchUserPublicKey, + fetchDMHistory, + decryptDm, sendDMViaWebSocket, fetchDMConversations, type DMConversationResponse @@ -19,9 +19,9 @@ export interface DMUser extends User { // Utility function for consistent username formatting in DM messages export function formatDMUsername( - senderId: number, - _recipientId: number, - currentUserId: number, + senderId: number, + _recipientId: number, + currentUserId: number, otherUsername: string ): string { const isFromCurrentUser = senderId === currentUserId; @@ -30,15 +30,15 @@ export function formatDMUsername( // Utility function for consistent message content formatting export function formatDMMessageContent( - content: string, - senderId: number, + content: string, + senderId: number, currentUserId: number ): string { const isFromCurrentUser = senderId === currentUserId; const prefix = isFromCurrentUser ? "Вы: " : ""; const maxContentLength = 50 - prefix.length; - const truncatedContent = content.length > maxContentLength - ? content.substring(0, maxContentLength) + "..." + const truncatedContent = content.length > maxContentLength + ? content.substring(0, maxContentLength) + "..." : content; return prefix + truncatedContent; } @@ -66,7 +66,7 @@ export function useDM() { // Find last message const lastMessage = messages[messages.length - 1]; let lastPlaintext: string | null = null; - + try { lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content; console.log(lastPlaintext); @@ -84,10 +84,10 @@ export function useDM() { } // Update user state - setDmUsersState(prev => prev.map(u => - u.id === dmUser.id - ? { - ...u, + setDmUsersState(prev => prev.map(u => + u.id === dmUser.id + ? { + ...u, lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined, unreadCount, publicKey @@ -102,24 +102,24 @@ export function useDM() { // Load DM conversations when chats tab is active const loadUsers = useCallback(async () => { if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return; - + usersLoadedRef.current = true; setIsLoadingUsers(true); try { const conversations = await fetchDMConversations(user.authToken); - + // Process conversations and decrypt last messages const dmUsersWithState: DMUser[] = await Promise.all( conversations.map(async (conv: DMConversationResponse) => { let lastMessageContent: string | undefined = undefined; - + if (conv.lastMessage) { try { // Get the public key for the other user - const otherUserId = conv.lastMessage.senderId === user.currentUser?.id - ? conv.lastMessage.recipientId + const otherUserId = conv.lastMessage.senderId === user.currentUser?.id + ? conv.lastMessage.recipientId : conv.lastMessage.senderId; - + const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { // Decrypt the last message @@ -131,7 +131,7 @@ export function useDM() { console.error("Failed to decrypt last message for user", conv.user.id, error); } } - + return { ...conv.user, unreadCount: conv.unreadCount, @@ -140,10 +140,10 @@ export function useDM() { }; }) ); - + setDmUsersState(dmUsersWithState); setDmUsers(conversations.map((conv: DMConversationResponse) => conv.user)); - + } catch (error) { console.error("Failed to load DM conversations:", error); } finally { @@ -159,7 +159,7 @@ export function useDM() { // Load DM history for active conversation const loadDMHistory = useCallback(async (userId: number, publicKey: string) => { if (!user.authToken || isLoadingHistory) return; - + setIsLoadingHistory(true); try { const messages = await fetchDMHistory(userId, user.authToken, 50); @@ -171,7 +171,7 @@ export function useDM() { const text = await decryptDm(env, publicKey); const isAuthor = env.senderId !== userId; const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User"; - + decryptedMessages.push({ id: env.id, content: text, @@ -196,7 +196,7 @@ export function useDM() { if (maxIncomingId > 0) { setLastReadId(userId, maxIncomingId); // Clear unread count - setDmUsersState(prev => prev.map(u => + setDmUsersState(prev => prev.map(u => u.id === userId ? { ...u, unreadCount: 0 } : u )); } @@ -257,17 +257,17 @@ export function useDM() { try { const conversations = await fetchDMConversations(user.authToken); const userConversation = conversations.find(conv => conv.user.id === userId); - + if (userConversation) { let lastMessageContent: string | undefined = undefined; - + if (userConversation.lastMessage) { try { // Get the public key for the other user - const otherUserId = userConversation.lastMessage.senderId === user.currentUser?.id - ? userConversation.lastMessage.recipientId + const otherUserId = userConversation.lastMessage.senderId === user.currentUser?.id + ? userConversation.lastMessage.recipientId : userConversation.lastMessage.senderId; - + const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { // Decrypt the last message @@ -279,7 +279,7 @@ export function useDM() { console.error("Failed to decrypt last message for user", userId, error); } } - + // Update the specific user in the state setDmUsersState(prev => prev.map(u => { if (u.id === userId) { @@ -311,13 +311,13 @@ export function useDM() { const msg = JSON.parse(e.data); if (msg.type === "dmNew") { const { senderId, recipientId, ...envelope } = msg.data; - + // Update conversation list (not active conversation - that's handled by DMPanel) if (!user.currentUser?.id) { return; } const otherUserId = senderId === user.currentUser.id ? recipientId : senderId; - + // Update unread count and last message preview try { const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); @@ -326,11 +326,11 @@ export function useDM() { const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const messageContent = decryptedData.data.content; const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); - - setDmUsersState(prev => prev.map(u => - u.id === otherUserId - ? { - ...u, + + setDmUsersState(prev => prev.map(u => + u.id === otherUserId + ? { + ...u, unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount, lastMessage: formattedMessage, publicKey @@ -343,7 +343,7 @@ export function useDM() { } } else if (msg.type === "dmEdited") { const { id, senderId, recipientId, ...envelope } = msg.data; - + // Update last message preview for conversation list if (!user.currentUser?.id) { return; @@ -356,10 +356,10 @@ export function useDM() { const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const messageContent = decryptedData.data.content; const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); - setDmUsersState(prev => prev.map(u => - u.id === otherUserId - ? { - ...u, + setDmUsersState(prev => prev.map(u => + u.id === otherUserId + ? { + ...u, lastMessage: formattedMessage, publicKey } @@ -371,7 +371,7 @@ export function useDM() { } } else if (msg.type === "dmDeleted") { const { senderId, recipientId } = msg.data; - + // Reload only the specific user's conversation if (!user.currentUser?.id) return; const otherUserId = senderId === user.currentUser.id ? recipientId : senderId; diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 8c4a8ae..ca18edb 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -9,6 +9,8 @@ import { restoreKeys } from "@/core/api/authApi"; import { API_BASE_URL } from "@/core/config"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; +import { onlineStatusManager } from "@/core/onlineStatusManager"; +import { typingManager } from "@/core/typingManager"; export type ChatTabs = "chats" | "channels" | "contacts"; @@ -25,7 +27,7 @@ export interface ProfileDialogData { } interface ActiveDM { - userId: number; + userId: number; username: string; publicKey: string | null } @@ -61,6 +63,9 @@ interface ChatState { pendingPanel?: MessagePanel | null; call: CallState; profileDialog: ProfileDialogData | null; + onlineStatuses: Map; + typingUsers: Map; // userId -> username + dmTypingUsers: Map; } export interface UserState { @@ -84,7 +89,7 @@ interface AppState { applyPendingPanel: () => void; switchToPublicChat: (chatName: string) => Promise; switchToDM: (dmData: DMPanelData) => Promise; - + // Call state startCall: (userId: number, username: string) => void; endCall: () => void; @@ -99,16 +104,22 @@ interface AppState { setRemoteVideoEnabled: (enabled: boolean) => void; setRemoteScreenSharing: (enabled: boolean) => void; toggleCallMinimized: () => void; - + // User state user: UserState; setUser: (token: string, user: User) => void; logout: () => void; restoreUserFromStorage: () => Promise; - + // Profile dialog state setProfileDialog: (data: ProfileDialogData | null) => void; closeProfileDialog: () => void; + + // Online status and typing state + updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => void; + addTypingUser: (userId: number, username: string) => void; + removeTypingUser: (userId: number) => void; + setDmTypingUser: (userId: number, isTyping: boolean) => void; } export const useAppState = create((set, get) => ({ @@ -146,7 +157,10 @@ export const useAppState = create((set, get) => ({ isRemoteVideoEnabled: false, isSharingScreen: false, isRemoteScreenSharing: false - } + }, + onlineStatuses: new Map(), + typingUsers: new Map(), + dmTypingUsers: new Map() }, addMessage: (message: Message) => set((state) => { // Check if message already exists to prevent duplicates @@ -154,7 +168,7 @@ export const useAppState = create((set, get) => ({ if (messageExists) { return state; // Return unchanged state if message already exists } - + return { chat: { ...state.chat, @@ -165,7 +179,7 @@ export const useAppState = create((set, get) => ({ updateMessage: (messageId: number, updatedMessage: Partial) => set((state) => ({ chat: { ...state.chat, - messages: state.chat.messages.map(msg => + messages: state.chat.messages.map(msg => msg.id === messageId ? { ...msg, ...updatedMessage } : msg ) } @@ -206,7 +220,7 @@ export const useAppState = create((set, get) => ({ activeDm: dm } })), - + // User state user: { currentUser: null, @@ -220,6 +234,10 @@ export const useAppState = create((set, get) => ({ } })); + // Initialize managers with auth token + onlineStatusManager.setAuthToken(token); + typingManager.setAuthToken(token); + // Store credentials in localStorage try { localStorage.setItem('authToken', token); @@ -250,6 +268,12 @@ export const useAppState = create((set, get) => ({ console.error('Failed to clear localStorage:', error); } + // Cleanup managers + onlineStatusManager.setAuthToken(null); + typingManager.setAuthToken(null); + onlineStatusManager.cleanup(); + typingManager.cleanup(); + set(() => ({ user: { currentUser: null, @@ -260,7 +284,7 @@ export const useAppState = create((set, get) => ({ restoreUserFromStorage: async () => { try { const token = localStorage.getItem('authToken'); - + if (token) { const response = await fetch(`${API_BASE_URL}/user/profile`, { headers: getAuthHeaders(token) @@ -277,6 +301,10 @@ export const useAppState = create((set, get) => ({ } })); + // Initialize managers with auth token + onlineStatusManager.setAuthToken(token); + typingManager.setAuthToken(token); + try { request({ type: "ping", @@ -296,7 +324,7 @@ export const useAppState = create((set, get) => ({ const initialized = await initialize(); if (initialized) { await subscribe(token); - + // For Electron, start the notification receiver if (isElectron) { await startElectronReceiver(); @@ -317,7 +345,7 @@ export const useAppState = create((set, get) => ({ localStorage.removeItem('currentUser'); } }, - + // Panel management setActivePanel: (panel: MessagePanel | null) => set((state) => ({ chat: { @@ -349,15 +377,15 @@ export const useAppState = create((set, get) => ({ pendingPanel: null } })), - + switchToPublicChat: async (chatName: string) => { const { user, chat } = get(); - + if (!user.authToken) return; - + // Start chat switching animation chat.setIsSwitching(true); - + // Create or get public chat panel let publicChatPanel = chat.publicChatPanel; if (!publicChatPanel) { @@ -368,10 +396,10 @@ export const useAppState = create((set, get) => ({ // Reset messages for the new chat publicChatPanel.clearMessages(); } - + // Activate panel await publicChatPanel.activate(); - + // Defer panel swap until animation switch-out completes set((state) => ({ chat: { @@ -380,19 +408,19 @@ export const useAppState = create((set, get) => ({ activeTab: "chats" } })); - + // Let MessagePanelRenderer handle the animation timing completely // It will set isChatSwitching to false when the fadeInDown animation completes }, - + switchToDM: async (dmData: DMPanelData) => { const { user, chat } = get(); - + if (!user.authToken) return; - + // Start chat switching animation chat.setIsSwitching(true); - + // Create or get DM panel let dmPanel = chat.dmPanel; if (!dmPanel) { @@ -402,13 +430,13 @@ export const useAppState = create((set, get) => ({ // Reset messages for the new DM dmPanel.clearMessages(); } - + // Set DM data dmPanel.setDMData(dmData); - + // Activate panel await dmPanel.activate(); - + // Defer panel swap until animation switch-out completes set((state) => ({ chat: { @@ -422,7 +450,7 @@ export const useAppState = create((set, get) => ({ activeTab: "chats" } })); - + // Let MessagePanelRenderer handle the animation timing completely // It will set isChatSwitching to false when the fadeInDown animation completes }, @@ -449,7 +477,7 @@ export const useAppState = create((set, get) => ({ } } })), - + endCall: () => set((state) => ({ chat: { ...state.chat, @@ -471,7 +499,7 @@ export const useAppState = create((set, get) => ({ } } })), - + setCallStatus: (status: CallStatus) => set((state) => ({ chat: { ...state.chat, @@ -482,7 +510,7 @@ export const useAppState = create((set, get) => ({ } } })), - + toggleMute: () => set((state) => ({ chat: { ...state.chat, @@ -492,7 +520,7 @@ export const useAppState = create((set, get) => ({ } } })), - + toggleCallMinimize: () => set((state) => ({ chat: { ...state.chat, @@ -524,7 +552,7 @@ export const useAppState = create((set, get) => ({ } } })), - + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({ chat: { ...state.chat, @@ -535,7 +563,7 @@ export const useAppState = create((set, get) => ({ } } })), - + setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({ chat: { ...state.chat, @@ -545,7 +573,7 @@ export const useAppState = create((set, get) => ({ } } })), - + toggleVideo: () => set((state) => ({ chat: { ...state.chat, @@ -555,7 +583,7 @@ export const useAppState = create((set, get) => ({ } } })), - + toggleScreenShare: () => set((state) => ({ chat: { ...state.chat, @@ -565,7 +593,7 @@ export const useAppState = create((set, get) => ({ } } })), - + setRemoteVideoEnabled: (enabled: boolean) => set((state) => ({ chat: { ...state.chat, @@ -575,7 +603,7 @@ export const useAppState = create((set, get) => ({ } } })), - + setRemoteScreenSharing: (enabled: boolean) => set((state) => ({ chat: { ...state.chat, @@ -594,7 +622,7 @@ export const useAppState = create((set, get) => ({ } } })), - + // Profile dialog state management setProfileDialog: (data: ProfileDialogData | null) => set((state) => ({ chat: { @@ -602,11 +630,52 @@ export const useAppState = create((set, get) => ({ profileDialog: data } })), - + closeProfileDialog: () => set((state) => ({ chat: { ...state.chat, profileDialog: null } - })) + })), + + // Online status and typing state management + updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => set((state) => ({ + chat: { + ...state.chat, + onlineStatuses: new Map(state.chat.onlineStatuses).set(userId, { online, lastSeen }) + } + })), + + addTypingUser: (userId: number, username: string) => set((state) => ({ + chat: { + ...state.chat, + typingUsers: new Map(state.chat.typingUsers).set(userId, username) + } + })), + + removeTypingUser: (userId: number) => set((state) => { + const newTypingUsers = new Map(state.chat.typingUsers); + newTypingUsers.delete(userId); + return { + chat: { + ...state.chat, + typingUsers: newTypingUsers + } + }; + }), + + setDmTypingUser: (userId: number, isTyping: boolean) => set((state) => { + const newDmTypingUsers = new Map(state.chat.dmTypingUsers); + if (isTyping) { + newDmTypingUsers.set(userId, true); + } else { + newDmTypingUsers.delete(userId); + } + return { + chat: { + ...state.chat, + dmTypingUsers: newDmTypingUsers + } + }; + }) })); \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 655bd40..e186c25 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -6,6 +6,8 @@ import defaultAvatar from "@/images/default-avatar.png"; import { confirm } from "mdui/functions/confirm"; import { updateProfile, uploadProfilePicture, fetchUserProfile } from "@/core/api/profileApi"; import { RichTextArea } from "@/core/components/RichTextArea"; +import { onlineStatusManager } from "@/core/onlineStatusManager"; +import { OnlineStatus } from "./right/OnlineStatus"; export function ProfileDialog() { const { chat, user, closeProfileDialog } = useAppState(); @@ -27,7 +29,7 @@ export function ProfileDialog() { if (backdropRef.current && dialogRef.current) { backdropRef.current.classList.remove('open'); dialogRef.current.classList.remove('open'); - + // Wait for animation to complete before closing setTimeout(() => { setIsOpen(false); @@ -100,15 +102,30 @@ export function ProfileDialog() { } }, [isOpen]); + // Subscribe to user's online status when dialog opens + useEffect(() => { + if (isOpen && currentData?.userId && !currentData.isOwnProfile) { + // Subscribe to the user's status + onlineStatusManager.subscribe(currentData.userId); + + // Cleanup function to unsubscribe when dialog closes + return () => { + if (currentData.userId) { + onlineStatusManager.unsubscribe(currentData.userId); + } + }; + } + }, [isOpen, currentData?.userId, currentData?.isOwnProfile]); + const hasChanges = useMemo(() => { if (!originalData || !currentData) return false; - + // Normalize values for comparison (handle empty strings, undefined, null) const normalizeValue = (value: string | undefined | null) => { if (value === null || value === undefined) return ""; return value.trim(); }; - + return ( normalizeValue(originalData.username) !== normalizeValue(currentData.username) || normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) || @@ -138,7 +155,7 @@ export function ProfileDialog() { if (backdropRef.current && dialogRef.current) { backdropRef.current.classList.remove('open'); dialogRef.current.classList.remove('open'); - + // Wait for animation to complete before closing setTimeout(() => { closeProfileDialog(); @@ -215,7 +232,7 @@ export function ProfileDialog() { // Update the original data to match current data setOriginalData(currentData); - + // Close dialog with animation after successful save triggerCloseAnimation(); } catch (error) { @@ -236,7 +253,7 @@ export function ProfileDialog() { if (!isOpen || !currentData) return null; return createPortal( -

{/* Profile Picture */}
- Profile Picture {currentData.isOwnProfile && ( -
@@ -279,12 +296,9 @@ export function ProfileDialog() { )} {/* Online Status */} - {currentData.online !== undefined && ( + {currentData?.userId && (
- - - {currentData.online ? "Онлайн" : "Оффлайн"} - +
)} diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index 9d85b9f..fd112a5 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -29,7 +29,7 @@ export function ChatHeader() {
setProfilePictureUrl(defaultAvatar)} /> diff --git a/frontend/src/pages/chat/ui/left/ChatTabs.tsx b/frontend/src/pages/chat/ui/left/ChatTabs.tsx index 01f6460..729cf55 100644 --- a/frontend/src/pages/chat/ui/left/ChatTabs.tsx +++ b/frontend/src/pages/chat/ui/left/ChatTabs.tsx @@ -12,8 +12,8 @@ export function ChatTabs() { return (
- diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx index 47401bf..c0c5d85 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -19,9 +19,9 @@ function BottomAppBar() { onSettingsOpenChange(true)}>
- diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index 7d4ea27..4c1bcfa 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -6,6 +6,8 @@ import { getAuthHeaders } from "@/core/api/authApi"; import { fetchUserPublicKey } from "@/core/api/dmApi"; import type { Message } from "@/core/types"; import { websocket } from "@/core/websocket"; +import { onlineStatusManager } from "@/core/onlineStatusManager"; +import { OnlineIndicator } from "../right/OnlineIndicator"; import defaultAvatar from "@/images/default-avatar.png"; interface PublicChat { @@ -31,7 +33,7 @@ type ChatItem = PublicChat | DMConversation; export function UnifiedChatsList() { const { user, switchToPublicChat, switchToDM, chat } = useAppState(); const { dmUsers, isLoadingUsers, loadUsers } = useDM(); - + const [publicChats] = useState([ { id: "general", name: "Общий чат", type: "public" }, { id: "general2", name: "Общий чат 2", type: "public" } @@ -52,7 +54,7 @@ export function UnifiedChatsList() { const data = await response.json(); if (data.messages && data.messages.length > 0) { const lastMessage = data.messages[data.messages.length - 1]; - + setLastMessages({ general: lastMessage, general2: lastMessage @@ -102,7 +104,7 @@ export function UnifiedChatsList() { const 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 @@ -128,7 +130,7 @@ export function UnifiedChatsList() { } else if (msg.type === "messageDeleted") { const deletedMessageId = msg.data?.message_id; let needsReload = false; - + setLastMessages(prev => { const updated = { ...prev }; publicChats.forEach(chat => { @@ -139,7 +141,7 @@ export function UnifiedChatsList() { }); return updated; }); - + if (needsReload) { loadLastMessages(); } @@ -153,6 +155,23 @@ export function UnifiedChatsList() { return () => websocket.removeEventListener("message", handleWebSocketMessage); }, [publicChats, 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]); + const formatPublicChatMessage = (chatId: string): string => { const lastMessage = lastMessages[chatId]; if (!lastMessage) { @@ -161,12 +180,12 @@ export function UnifiedChatsList() { const isCurrentUser = lastMessage.username === user.currentUser?.username; const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `; - + const maxContentLength = 50 - prefix.length; - const content = lastMessage.content.length > maxContentLength - ? lastMessage.content.substring(0, maxContentLength) + "..." + const content = lastMessage.content.length > maxContentLength + ? lastMessage.content.substring(0, maxContentLength) + "..." : lastMessage.content; - + return prefix + content; }; @@ -179,7 +198,7 @@ export function UnifiedChatsList() { if (!dmConversation.publicKey) { const authToken = useAppState.getState().user.authToken; if (!authToken) return; - + const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); if (publicKey) { dmConversation.publicKey = publicKey; @@ -188,7 +207,7 @@ export function UnifiedChatsList() { return; } } - + await switchToDM({ userId: dmConversation.id, username: dmConversation.username, @@ -220,9 +239,9 @@ export function UnifiedChatsList() { {formatPublicChatMessage(chat.id)} )} - {chat.name} {chat.lastMessage || "Нет сообщений"} - {chat.username} { - (e.target as HTMLImageElement).src = defaultAvatar; - }} - /> +
+ {chat.username} { + (e.target as HTMLImageElement).src = defaultAvatar; + }} + /> + +
{chat.unreadCount > 0 && ( {chat.unreadCount} diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index af8e328..800c194 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from "react"; import { useAppState } from "@/pages/chat/state"; import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi"; import type { User } from "@/core/types"; +import { onlineStatusManager } from "@/core/onlineStatusManager"; +import { OnlineIndicator } from "../right/OnlineIndicator"; import defaultAvatar from "@/images/default-avatar.png"; import SearchBar from "@/core/components/SearchBar"; @@ -51,6 +53,21 @@ export function UsernameSearch() { }; }, [searchQuery, user.authToken]); + // Subscribe to online status for all search results + useEffect(() => { + // Subscribe to all search results + searchResults.forEach(searchUser => { + onlineStatusManager.subscribe(searchUser.id); + }); + + // Cleanup function to unsubscribe from all users + return () => { + searchResults.forEach(searchUser => { + onlineStatusManager.unsubscribe(searchUser.id); + }); + }; + }, [searchResults]); + async function handleUserClick(searchUser: SearchUser) { if (!user.authToken) return; @@ -133,17 +150,23 @@ export function UsernameSearch() { onClick={() => handleUserClick(searchUser)} style={{ cursor: "pointer" }} > - - {searchUser.online ? "В сети" : "Не в сети"} - - {searchUser.username} { - (e.target as HTMLImageElement).src = defaultAvatar; - }} - /> +
+ {searchUser.username} { + (e.target as HTMLImageElement).src = defaultAvatar; + }} + /> + +
))} diff --git a/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx b/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx index 36ceb00..7de4a5b 100644 --- a/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx +++ b/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx @@ -55,13 +55,13 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) function handleMouseDown(e: React.MouseEvent) { if (!isLoaded) return; - + const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const x = e.clientX - rect.left; const y = e.clientY - rect.top; - + // Check if click is within crop area if (x >= cropArea.x && x <= cropArea.x + cropArea.width && y >= cropArea.y && y <= cropArea.y + cropArea.height) { @@ -77,16 +77,16 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) const y = e.clientY - rect.top; const newX = Math.max( - 0, + 0, Math.min( - x - dragStart.x, + x - dragStart.x, imageRef.current.naturalWidth - cropArea.width ) ); const newY = Math.max( - 0, + 0, Math.min( - y - dragStart.y, + y - dragStart.y, imageRef.current.naturalHeight - cropArea.height ) ); @@ -162,7 +162,7 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) ref={canvasRef} width={400} height={400} - style={{ + style={{ cursor: isDragging ? 'grabbing' : 'grab', border: '1px solid #ccc', maxWidth: '100%', diff --git a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx index 1ea0bb6..e517209 100644 --- a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx @@ -33,22 +33,22 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { const initialized = await initialize(); if (initialized) { await subscribe(user.authToken); - + // For Electron, start the notification receiver if (isElectron) { await startElectronReceiver(); } - + setPushNotificationsEnabled(true); } } else { await unsubscribe(); - + // For Electron, stop the notification receiver if (isElectron) { stopElectronReceiver(); } - + // Call API to unsubscribe on server (for web browsers) await fetch(`${API_BASE_URL}/push/unsubscribe`, { method: "DELETE", @@ -71,63 +71,63 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
- handlePanelChange("notifications-settings")} style={{ cursor: "pointer" }} > Уведомления - handlePanelChange("appearance-settings")} style={{ cursor: "pointer" }} > Внешний вид - handlePanelChange("security-settings")} style={{ cursor: "pointer" }} > Безопасность - handlePanelChange("language-settings")} style={{ cursor: "pointer" }} > Язык - handlePanelChange("storage-settings")} style={{ cursor: "pointer" }} > Хранилище - handlePanelChange("help-settings")} style={{ cursor: "pointer" }} > Помощь - handlePanelChange("about-settings")} style={{ cursor: "pointer" }} @@ -139,7 +139,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {

Уведомления

{pushSupported && ( - handlePushNotificationToggle((e.target as Switch).checked)} > @@ -151,7 +151,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { Уведомления о статусе Email уведомления
- +

Внешний вид

@@ -165,14 +165,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { Большой
- +

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

Изменить пароль Двухфакторная аутентификация Автоматический выход
- +

Язык

@@ -181,21 +181,21 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { Español
- +

Хранилище

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

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

Помощь

Руководство пользователя Связаться с поддержкой FAQ
- +

О приложении

Версия: 1.0.0

diff --git a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx index 59ed54c..c718d1d 100644 --- a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx +++ b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx @@ -20,22 +20,26 @@ interface ChatInputWrapperProps { onCloseEdit?: () => void; onProvideFileAdder?: (adder: (files: File[]) => void) => void; messagePanelRef?: React.RefObject; + onTyping?: () => void; + onStopTyping?: () => void; } export function ChatInputWrapper( - { - onSendMessage, - onSaveEdit, - replyTo, - replyToVisible, + { + onSendMessage, + onSaveEdit, + replyTo, + replyToVisible, onClearReply, - onCloseReply, - editingMessage, - editVisible = false, - onClearEdit, + onCloseReply, + editingMessage, + editVisible = false, + onClearEdit, onCloseEdit, onProvideFileAdder, - messagePanelRef + messagePanelRef, + onTyping, + onStopTyping }: ChatInputWrapperProps ) { const [message, setMessage] = useState(""); @@ -73,7 +77,7 @@ export function ChatInputWrapper( if (chatInputWrapperRef.current && messagePanelRef?.current) { const inputRect = chatInputWrapperRef.current.getBoundingClientRect(); const panelRect = messagePanelRef.current.getBoundingClientRect(); - + // Position menu 10px from message panel edge and 10px above the chat input // The animation will start 30px below this position setEmojiMenuPosition({ @@ -91,6 +95,17 @@ export function ChatInputWrapper( setMessage(prev => prev + emoji); }; + function handleTyping() { + if (onTyping) { + onTyping(); + } + }; + + function handleMessageChange(value: string) { + setMessage(value); + handleTyping(); + }; + async function handleSubmit(e: React.FormEvent | Event) { e.preventDefault(); const hasText = Boolean(message.trim()); @@ -111,6 +126,8 @@ export function ChatInputWrapper( setMessage(""); setAttachmentsVisible(false); if (onClearReply) onClearReply(); + // Stop typing indicator when message is sent + if (onStopTyping) onStopTyping(); } } }; @@ -190,13 +207,13 @@ export function ChatInputWrapper( className="emoji-btn" />
setMessage(value)} + onTextChange={handleMessageChange} onEnter={handleSubmit} />
@@ -211,7 +228,7 @@ export function ChatInputWrapper(
Общий размер вложений превышает 4 ГБ.
setErrorOpen(false)}>Закрыть - + setEmojiMenuOpen(false)} diff --git a/frontend/src/pages/chat/ui/right/ChatMessages.tsx b/frontend/src/pages/chat/ui/right/ChatMessages.tsx index 36c6cd4..704b6c6 100644 --- a/frontend/src/pages/chat/ui/right/ChatMessages.tsx +++ b/frontend/src/pages/chat/ui/right/ChatMessages.tsx @@ -20,9 +20,9 @@ interface ChatMessagesProps { export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) { const { user } = useAppState(); - + // Use prop messages (panels provide their own messages) - + // Context menu state const [contextMenu, setContextMenu] = useState({ isOpen: false, @@ -89,13 +89,13 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel async function handleReactionClick(messageId: number, emoji: string) { if (!user.authToken) return; - + try { if (isDm) { // For DM messages, we need to find the dm_envelope_id from the message const message = messages.find(m => m.id === messageId); const dmEnvelopeId = message?.runtimeData?.dmEnvelope?.id; - + if (dmEnvelopeId) { await request({ type: "addDmReaction", @@ -131,8 +131,8 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel - + setDeleteDialogOpen(false)}>Отменить Удалить - + {/* Context Menu */} {contextMenu.message && ( { if (!scrollRef.current) return; - + // Find which category is currently visible for (const [categoryName, element] of categoryRefs.current) { if (element) { const rect = element.getBoundingClientRect(); const containerRect = scrollRef.current.getBoundingClientRect(); - + // Check if category header is in view if (rect.top <= containerRect.top + 50 && rect.bottom > containerRect.top + 50) { if (activeCategory !== categoryName) { @@ -60,9 +60,9 @@ export function EmojiMenu(props: EmojiMenuProps) { function scrollToCategory(categoryName: string) { const element = categoryRefs.current.get(categoryName); if (element && scrollRef.current) { - element.scrollIntoView({ - behavior: 'smooth', - block: 'start' + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' }); } } @@ -72,7 +72,7 @@ export function EmojiMenu(props: EmojiMenuProps) { if (tabElement && tabsRef.current) { const tabsRect = tabsRef.current.getBoundingClientRect(); const tabRect = tabElement.getBoundingClientRect(); - + // Check if tab is outside the visible area if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) { tabElement.scrollIntoView({ @@ -116,7 +116,7 @@ export function EmojiMenu(props: EmojiMenuProps) { return ( -
- -
{EMOJI_CATEGORIES.map((category) => { const emojis = category.name === "recent" ? recentEmojis : category.emojis; - + return ( -
{ if (el) categoryRefs.current.set(category.name, el); diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index d844941..11afc6d 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -84,7 +84,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr // Update existing reactions and add new ones setVisibleReactions(prev => { const updated = [...prev]; - + // Update existing reactions uniqueReactions.forEach(reaction => { const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji); @@ -97,7 +97,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr } } }); - + return updated; }); }, [reactions]); @@ -112,7 +112,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr {visibleReactions.map((reaction, index) => { const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id); const isAnimating = animatingReactions.has(reaction.emoji); - + return (