From 1e86b9fc843342159117289cf0e69c503730ec59 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 18 Oct 2025 22:50:13 +0300 Subject: [PATCH] Implement real-time online status and typing indicator --- backend/routes/messaging.py | 238 ++++++++++++++++++ frontend/src/core/onlineStatusManager.ts | 152 +++++++++++ frontend/src/core/types.d.ts | 90 +++++++ frontend/src/core/typingManager.ts | 216 ++++++++++++++++ frontend/src/core/websocket.ts | 15 ++ frontend/src/pages/chat/css/_chat-input.scss | 2 + frontend/src/pages/chat/css/_left-panel.scss | 1 - frontend/src/pages/chat/css/_right-panel.scss | 9 - .../pages/chat/css/_typing-indicators.scss | 109 ++++++++ frontend/src/pages/chat/css/chat.scss | 3 +- frontend/src/pages/chat/state.ts | 73 +++++- frontend/src/pages/chat/ui/ProfileDialog.tsx | 24 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 50 ++-- .../src/pages/chat/ui/left/UsernameSearch.tsx | 45 +++- .../pages/chat/ui/right/ChatInputWrapper.tsx | 17 +- .../chat/ui/right/MessagePanelRenderer.tsx | 53 +++- .../pages/chat/ui/right/OnlineIndicator.tsx | 29 +++ .../src/pages/chat/ui/right/OnlineStatus.tsx | 58 +++++ .../pages/chat/ui/right/TypingIndicator.tsx | 36 +++ .../src/pages/chat/ui/right/panels/DMPanel.ts | 28 ++- 20 files changed, 1191 insertions(+), 57 deletions(-) create mode 100644 frontend/src/core/onlineStatusManager.ts create mode 100644 frontend/src/core/typingManager.ts create mode 100644 frontend/src/pages/chat/css/_typing-indicators.scss create mode 100644 frontend/src/pages/chat/ui/right/OnlineIndicator.tsx create mode 100644 frontend/src/pages/chat/ui/right/OnlineStatus.tsx create mode 100644 frontend/src/pages/chat/ui/right/TypingIndicator.tsx diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index d24dacc..319a75d 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 @@ -661,11 +663,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"] @@ -687,6 +697,14 @@ 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", @@ -1040,6 +1058,137 @@ 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) + + import time + 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"]) + import time + + 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"}}) @@ -1057,9 +1206,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: @@ -1069,8 +1233,82 @@ class MessaggingSocketManager: for websocket in self.connections: 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/onlineStatusManager.ts b/frontend/src/core/onlineStatusManager.ts new file mode 100644 index 0000000..3d4828c --- /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/types.d.ts b/frontend/src/core/types.d.ts index 6b9382c..e356d82 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -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..61a8fa3 --- /dev/null +++ b/frontend/src/core/typingManager.ts @@ -0,0 +1,216 @@ +/** + * @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); + } + } + + /** + * 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..56801b7 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 @@ -116,6 +118,19 @@ websocket.addEventListener("message", (e) => { 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/pages/chat/css/_chat-input.scss b/frontend/src/pages/chat/css/_chat-input.scss index 9ac5779..d7096fc 100644 --- a/frontend/src/pages/chat/css/_chat-input.scss +++ b/frontend/src/pages/chat/css/_chat-input.scss @@ -115,6 +115,8 @@ } } +// Typing indicator styles + // Emoji Menu Styles .emoji-menu { $transition: cubic-bezier(0.4, 0, 0.2, 1); diff --git a/frontend/src/pages/chat/css/_left-panel.scss b/frontend/src/pages/chat/css/_left-panel.scss index 4f4eced..b4d9753 100644 --- a/frontend/src/pages/chat/css/_left-panel.scss +++ b/frontend/src/pages/chat/css/_left-panel.scss @@ -156,7 +156,6 @@ height: 45px; border-radius: 20%; object-fit: cover; - margin-right: 1rem; } mdui-tabs { diff --git a/frontend/src/pages/chat/css/_right-panel.scss b/frontend/src/pages/chat/css/_right-panel.scss index 3777b8b..615bd16 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; 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/state.ts b/frontend/src/pages/chat/state.ts index 8c4a8ae..d14176e 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"; @@ -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 { @@ -109,6 +114,12 @@ interface AppState { // 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 @@ -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, @@ -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", @@ -608,5 +636,46 @@ export const useAppState = create((set, get) => ({ ...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..b3364f7 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(); @@ -100,6 +102,21 @@ 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; @@ -279,12 +296,9 @@ export function ProfileDialog() { )} {/* Online Status */} - {currentData.online !== undefined && ( + {currentData.userId && !currentData.isOwnProfile && (
- - - {currentData.online ? "Онлайн" : "Оффлайн"} - +
)} diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index 7d4ea27..33b4ebc 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 { @@ -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) { @@ -244,20 +263,23 @@ export function UnifiedChatsList() { {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..bac2c82 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/right/ChatInputWrapper.tsx b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx index 59ed54c..0f03d20 100644 --- a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx +++ b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx @@ -20,6 +20,7 @@ interface ChatInputWrapperProps { onCloseEdit?: () => void; onProvideFileAdder?: (adder: (files: File[]) => void) => void; messagePanelRef?: React.RefObject; + onTyping?: () => void; } export function ChatInputWrapper( @@ -35,7 +36,8 @@ export function ChatInputWrapper( onClearEdit, onCloseEdit, onProvideFileAdder, - messagePanelRef + messagePanelRef, + onTyping }: ChatInputWrapperProps ) { const [message, setMessage] = useState(""); @@ -91,6 +93,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()); @@ -196,7 +209,7 @@ export function ChatInputWrapper( autoComplete="off" text={message} rows={1} - onTextChange={(value) => setMessage(value)} + onTextChange={handleMessageChange} onEnter={handleSubmit} />
diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index 26c4857..d09ff52 100644 --- a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -1,20 +1,49 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { useAppState } from "@/pages/chat/state"; import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel"; import { ChatMessages } from "./ChatMessages"; import { ChatInputWrapper } from "./ChatInputWrapper"; -import { ProfileDialog } from "../ProfileDialog"; +import { ProfileDialog } from "@/pages/chat/ui/ProfileDialog"; import { setGlobalMessageHandler } from "@/core/websocket"; import type { Message, WebSocketMessage } from "@/core/types"; import defaultAvatar from "@/images/default-avatar.png"; import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity"; -import type { DMPanel } from "./panels/DMPanel"; +import { DMPanel } from "./panels/DMPanel"; import useCall from "@/pages/chat/hooks/useCall"; +import { TypingIndicator } from "./TypingIndicator"; +import { OnlineStatus } from "./OnlineStatus"; +import { typingManager } from "@/core/typingManager"; +import { PublicChatPanel } from "./panels/PublicChatPanel"; interface MessagePanelRendererProps { panel: MessagePanel | null; } +function ChatHeaderText({ panel }: { panel: MessagePanel | null }) { + const { chat, user } = useAppState(); + const otherTypingUsers = useMemo(() => { + return Array + .from(chat.typingUsers.entries()) + .filter(([userId, username]) => userId !== user.currentUser?.id && username) + .map(([, username]) => username!); + }, [chat.typingUsers, user.currentUser?.id]); + + let content: ReactNode; + + if (panel instanceof DMPanel) { + const recipientId = panel.getRecipientId()!; + const isTyping = chat.dmTypingUsers.get(recipientId); + + content = isTyping ? : ; + } else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) { + content = ; + } else { + return null; + } + + return
{content}
; +} + export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { const { applyPendingPanel, chat, setProfileDialog } = useAppState(); const messagePanelRef = useRef(null); @@ -233,14 +262,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {

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

-

- - {panelState ? ( - panelState.online ? "Online" : "Offline" - ) : ( - "Выберите чат, чтобы начать переписку" - )} -

+
{panel?.isDm() && ( @@ -315,6 +337,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
+ { panel.handleSendMessage(text, replyTo?.id, files); @@ -354,6 +377,14 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { }} onProvideFileAdder={(adder) => { addFilesRef.current = adder; }} messagePanelRef={messagePanelRef} + onTyping={() => { + if (panel.isDm()) { + const dmPanel = panel as DMPanel; + dmPanel.handleTyping(); + } else { + typingManager.sendTyping(); + } + }} /> )} diff --git a/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx b/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx new file mode 100644 index 0000000..87de550 --- /dev/null +++ b/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx @@ -0,0 +1,29 @@ +/** + * @fileoverview Online indicator component for profile pictures + * @description Shows a small dot at the bottom right of profile pictures to indicate online status + * @author Cursor + * @version 1.0.0 + */ + +import { useAppState } from "@/pages/chat/state"; + +interface OnlineIndicatorProps { + userId: number; + className?: string; +} + +export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps) { + const { chat } = useAppState(); + const status = chat.onlineStatuses.get(userId); + + // Only show indicator when user is online + if (!status || !status.online) { + return null; + } + + return ( +
+
+
+ ); +} diff --git a/frontend/src/pages/chat/ui/right/OnlineStatus.tsx b/frontend/src/pages/chat/ui/right/OnlineStatus.tsx new file mode 100644 index 0000000..339c68d --- /dev/null +++ b/frontend/src/pages/chat/ui/right/OnlineStatus.tsx @@ -0,0 +1,58 @@ +/** + * @fileoverview Online status component for showing user online status + * @description Displays online/offline status with last seen timestamp + * @author Cursor + * @version 1.0.0 + */ + +import { useAppState } from "@/pages/chat/state"; + +interface OnlineStatusProps { + userId: number; + className?: string; + showLastSeen?: boolean; +} + +export function OnlineStatus({ userId, className = "", showLastSeen = false }: OnlineStatusProps) { + const { chat } = useAppState(); + const status = chat.onlineStatuses.get(userId); + + if (!status) { + return null; + } + + const formatLastSeen = (lastSeen: string): string => { + const date = new Date(lastSeen); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) { + return "только что"; + } else if (diffMins < 60) { + return `${diffMins} мин. назад`; + } else if (diffHours < 24) { + return `${diffHours} ч. назад`; + } else if (diffDays < 7) { + return `${diffDays} дн. назад`; + } else { + return date.toLocaleDateString(); + } + }; + + return ( +
+
+ + {status.online ? "В сети" : "Не в сети"} + + {showLastSeen && !status.online && ( + + {formatLastSeen(status.lastSeen)} + + )} +
+ ); +} diff --git a/frontend/src/pages/chat/ui/right/TypingIndicator.tsx b/frontend/src/pages/chat/ui/right/TypingIndicator.tsx new file mode 100644 index 0000000..cab40a1 --- /dev/null +++ b/frontend/src/pages/chat/ui/right/TypingIndicator.tsx @@ -0,0 +1,36 @@ +/** + * @fileoverview Typing indicator component for showing who is typing + * @description Displays a list of users who are currently typing + * @author Cursor + * @version 1.0.0 + */ + +import { useMemo } from "react"; + +interface TypingIndicatorProps { + typingUsers: string[]; // Array of usernames who are typing +} + +export function TypingIndicator({ typingUsers }: TypingIndicatorProps) { + // Format the typing text based on number of users + const typingText = useMemo(() => { + switch (typingUsers.length) { + case 0: return "печатает..."; + case 1: return `${typingUsers[0]} печатает...`; + case 2: return `${typingUsers[0]} и ${typingUsers[1]} печатают...`; + default: return `${typingUsers[0]}, ${typingUsers[1]} и еще ${typingUsers.length - 2} печатают...`; + } + }, [typingUsers]); + + return ( +
+
+ + + +
+ {typingText} +
+ ); +} + diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index f0ddd2e..88757a1 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -11,6 +11,8 @@ import { fetchUserProfile } from "@/core/api/profileApi"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/pages/chat/state"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; +import { onlineStatusManager } from "@/core/onlineStatusManager"; +import { typingManager } from "@/core/typingManager"; export interface DMPanelData { userId: number; @@ -34,13 +36,25 @@ export class DMPanel extends MessagePanel { return true; } + getRecipientId(): number | null { + return this.dmData?.userId || null; + } + async activate(): Promise { // Don't load messages immediately during activation to prevent animation freeze // Messages will be loaded after the animation completes + + // Subscribe to recipient's online status + if (this.dmData?.userId) { + onlineStatusManager.subscribe(this.dmData.userId); + } } deactivate(): void { - // DM doesn't need special cleanup + // Unsubscribe from recipient's online status + if (this.dmData?.userId) { + onlineStatusManager.unsubscribe(this.dmData.userId); + } } clearMessages(): void { @@ -254,6 +268,11 @@ export class DMPanel extends MessagePanel { // Reset for DM switching reset(): void { + // Unsubscribe from current recipient's status before switching + if (this.dmData?.userId) { + onlineStatusManager.unsubscribe(this.dmData.userId); + } + this.dmData = null; this.messagesLoaded = false; this.clearMessages(); @@ -280,6 +299,13 @@ export class DMPanel extends MessagePanel { return this.dmData?.username || null; } + // Handle typing in DM + handleTyping(): void { + if (this.dmData?.userId) { + typingManager.sendDmTyping(this.dmData.userId); + } + } + // Helper functions for localStorage private getLastReadId(userId: number): number { try {