mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement real-time online status and typing indicator
This commit is contained in:
@@ -4,6 +4,8 @@ from pathlib import Path
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
@@ -661,11 +663,19 @@ class MessaggingSocketManager:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.connections: list[WebSocket] = []
|
self.connections: list[WebSocket] = []
|
||||||
self.user_by_ws: dict[WebSocket, int] = {}
|
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):
|
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}})
|
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
|
||||||
|
|
||||||
async def handle_connection(self, websocket: WebSocket, db: Session):
|
async def handle_connection(self, websocket: WebSocket, db: Session):
|
||||||
|
# Initialize subscriptions for this connection
|
||||||
|
self.ws_subscriptions[websocket] = set()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
data = await websocket.receive_json()
|
data = await websocket.receive_json()
|
||||||
type = data["type"]
|
type = data["type"]
|
||||||
@@ -687,6 +697,14 @@ class MessaggingSocketManager:
|
|||||||
current_user = get_current_user_inner()
|
current_user = get_current_user_inner()
|
||||||
if current_user:
|
if current_user:
|
||||||
self.user_by_ws[websocket] = current_user.id
|
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:
|
else:
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "ping",
|
"type": "ping",
|
||||||
@@ -1040,6 +1058,137 @@ class MessaggingSocketManager:
|
|||||||
await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}})
|
await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}})
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
await self.send_error(websocket, type, 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:
|
else:
|
||||||
await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}})
|
await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}})
|
||||||
|
|
||||||
@@ -1057,9 +1206,24 @@ class MessaggingSocketManager:
|
|||||||
except WebSocketDisconnect as e:
|
except WebSocketDisconnect as e:
|
||||||
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
|
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
|
||||||
finally:
|
finally:
|
||||||
|
# Cleanup connection
|
||||||
self.connections.remove(websocket)
|
self.connections.remove(websocket)
|
||||||
if websocket in self.user_by_ws:
|
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]
|
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):
|
async def broadcast(self, message: dict):
|
||||||
for websocket in self.connections:
|
for websocket in self.connections:
|
||||||
@@ -1070,7 +1234,81 @@ class MessaggingSocketManager:
|
|||||||
if self.user_by_ws.get(websocket) == user_id:
|
if self.user_by_ws.get(websocket) == user_id:
|
||||||
await websocket.send_json(message)
|
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()
|
messagingManager = MessaggingSocketManager()
|
||||||
|
# Start the cleanup task
|
||||||
|
messagingManager.start_cleanup_task()
|
||||||
|
|
||||||
@router.websocket("/chat/ws")
|
@router.websocket("/chat/ws")
|
||||||
async def chat_websocket(
|
async def chat_websocket(
|
||||||
|
|||||||
@@ -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<number> = new Set();
|
||||||
|
private statusCache: Map<number, UserStatus> = 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<number, UserStatus> {
|
||||||
|
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<number> {
|
||||||
|
return new Set(this.subscribedUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from all users and clear cache
|
||||||
|
*/
|
||||||
|
async unsubscribeAll(): Promise<void> {
|
||||||
|
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();
|
||||||
Vendored
+90
@@ -531,3 +531,93 @@ export interface CallScreenShareToggleMessage extends CallSignalingMessage {
|
|||||||
type: "call_screen_share_toggle";
|
type: "call_screen_share_toggle";
|
||||||
data: CallScreenShareToggleData;
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, NodeJS.Timeout> = 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
@@ -9,6 +9,8 @@ import { API_WS_BASE_URL } from "./config";
|
|||||||
import type { WebSocketMessage } from "./types";
|
import type { WebSocketMessage } from "./types";
|
||||||
import { delay } from "@/utils/utils";
|
import { delay } from "@/utils/utils";
|
||||||
import { CallSignalingHandler } from "./calls/signaling";
|
import { CallSignalingHandler } from "./calls/signaling";
|
||||||
|
import { onlineStatusManager } from "./onlineStatusManager";
|
||||||
|
import { typingManager } from "./typingManager";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new WebSocket connection to the chat server
|
* Creates a new WebSocket connection to the chat server
|
||||||
@@ -116,6 +118,19 @@ websocket.addEventListener("message", (e) => {
|
|||||||
callSignalingHandler.handleWebSocketMessage(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
|
// Route message to global handler if set
|
||||||
if (globalMessageHandler) {
|
if (globalMessageHandler) {
|
||||||
globalMessageHandler(response);
|
globalMessageHandler(response);
|
||||||
|
|||||||
@@ -115,6 +115,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Typing indicator styles
|
||||||
|
|
||||||
// Emoji Menu Styles
|
// Emoji Menu Styles
|
||||||
.emoji-menu {
|
.emoji-menu {
|
||||||
$transition: cubic-bezier(0.4, 0, 0.2, 1);
|
$transition: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|||||||
@@ -156,7 +156,6 @@
|
|||||||
height: 45px;
|
height: 45px;
|
||||||
border-radius: 20%;
|
border-radius: 20%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mdui-tabs {
|
mdui-tabs {
|
||||||
|
|||||||
@@ -43,15 +43,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.online-status {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: $success;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,3 +10,4 @@
|
|||||||
@use "animations";
|
@use "animations";
|
||||||
@use "callWindow";
|
@use "callWindow";
|
||||||
@use "profile-dialog";
|
@use "profile-dialog";
|
||||||
|
@use "typing-indicators";
|
||||||
@@ -9,6 +9,8 @@ import { restoreKeys } from "@/core/api/authApi";
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
|
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
|
||||||
import { isElectron } from "@/core/electron/electron";
|
import { isElectron } from "@/core/electron/electron";
|
||||||
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
|
import { typingManager } from "@/core/typingManager";
|
||||||
|
|
||||||
export type ChatTabs = "chats" | "channels" | "contacts";
|
export type ChatTabs = "chats" | "channels" | "contacts";
|
||||||
|
|
||||||
@@ -61,6 +63,9 @@ interface ChatState {
|
|||||||
pendingPanel?: MessagePanel | null;
|
pendingPanel?: MessagePanel | null;
|
||||||
call: CallState;
|
call: CallState;
|
||||||
profileDialog: ProfileDialogData | null;
|
profileDialog: ProfileDialogData | null;
|
||||||
|
onlineStatuses: Map<number, {online: boolean, lastSeen: string}>;
|
||||||
|
typingUsers: Map<number, string>; // userId -> username
|
||||||
|
dmTypingUsers: Map<number, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserState {
|
export interface UserState {
|
||||||
@@ -109,6 +114,12 @@ interface AppState {
|
|||||||
// Profile dialog state
|
// Profile dialog state
|
||||||
setProfileDialog: (data: ProfileDialogData | null) => void;
|
setProfileDialog: (data: ProfileDialogData | null) => void;
|
||||||
closeProfileDialog: () => 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<AppState>((set, get) => ({
|
export const useAppState = create<AppState>((set, get) => ({
|
||||||
@@ -146,7 +157,10 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
isRemoteVideoEnabled: false,
|
isRemoteVideoEnabled: false,
|
||||||
isSharingScreen: false,
|
isSharingScreen: false,
|
||||||
isRemoteScreenSharing: false
|
isRemoteScreenSharing: false
|
||||||
}
|
},
|
||||||
|
onlineStatuses: new Map(),
|
||||||
|
typingUsers: new Map(),
|
||||||
|
dmTypingUsers: new Map()
|
||||||
},
|
},
|
||||||
addMessage: (message: Message) => set((state) => {
|
addMessage: (message: Message) => set((state) => {
|
||||||
// Check if message already exists to prevent duplicates
|
// Check if message already exists to prevent duplicates
|
||||||
@@ -220,6 +234,10 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Initialize managers with auth token
|
||||||
|
onlineStatusManager.setAuthToken(token);
|
||||||
|
typingManager.setAuthToken(token);
|
||||||
|
|
||||||
// Store credentials in localStorage
|
// Store credentials in localStorage
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('authToken', token);
|
localStorage.setItem('authToken', token);
|
||||||
@@ -250,6 +268,12 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
console.error('Failed to clear localStorage:', error);
|
console.error('Failed to clear localStorage:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup managers
|
||||||
|
onlineStatusManager.setAuthToken(null);
|
||||||
|
typingManager.setAuthToken(null);
|
||||||
|
onlineStatusManager.cleanup();
|
||||||
|
typingManager.cleanup();
|
||||||
|
|
||||||
set(() => ({
|
set(() => ({
|
||||||
user: {
|
user: {
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
@@ -277,6 +301,10 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Initialize managers with auth token
|
||||||
|
onlineStatusManager.setAuthToken(token);
|
||||||
|
typingManager.setAuthToken(token);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
request({
|
request({
|
||||||
type: "ping",
|
type: "ping",
|
||||||
@@ -608,5 +636,46 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
...state.chat,
|
...state.chat,
|
||||||
profileDialog: null
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
@@ -6,6 +6,8 @@ import defaultAvatar from "@/images/default-avatar.png";
|
|||||||
import { confirm } from "mdui/functions/confirm";
|
import { confirm } from "mdui/functions/confirm";
|
||||||
import { updateProfile, uploadProfilePicture, fetchUserProfile } from "@/core/api/profileApi";
|
import { updateProfile, uploadProfilePicture, fetchUserProfile } from "@/core/api/profileApi";
|
||||||
import { RichTextArea } from "@/core/components/RichTextArea";
|
import { RichTextArea } from "@/core/components/RichTextArea";
|
||||||
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
|
import { OnlineStatus } from "./right/OnlineStatus";
|
||||||
|
|
||||||
export function ProfileDialog() {
|
export function ProfileDialog() {
|
||||||
const { chat, user, closeProfileDialog } = useAppState();
|
const { chat, user, closeProfileDialog } = useAppState();
|
||||||
@@ -100,6 +102,21 @@ export function ProfileDialog() {
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [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(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (!originalData || !currentData) return false;
|
if (!originalData || !currentData) return false;
|
||||||
|
|
||||||
@@ -279,12 +296,9 @@ export function ProfileDialog() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Online Status */}
|
{/* Online Status */}
|
||||||
{currentData.online !== undefined && (
|
{currentData.userId && !currentData.isOwnProfile && (
|
||||||
<div className="online-status-section">
|
<div className="online-status-section">
|
||||||
<span className={`online-indicator ${currentData.online ? "" : "offline"}`} />
|
<OnlineStatus userId={currentData.userId} />
|
||||||
<span className="status-text">
|
|
||||||
{currentData.online ? "Онлайн" : "Оффлайн"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { getAuthHeaders } from "@/core/api/authApi";
|
|||||||
import { fetchUserPublicKey } from "@/core/api/dmApi";
|
import { fetchUserPublicKey } from "@/core/api/dmApi";
|
||||||
import type { Message } from "@/core/types";
|
import type { Message } from "@/core/types";
|
||||||
import { websocket } from "@/core/websocket";
|
import { websocket } from "@/core/websocket";
|
||||||
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
|
import { OnlineIndicator } from "../right/OnlineIndicator";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
|
|
||||||
interface PublicChat {
|
interface PublicChat {
|
||||||
@@ -153,6 +155,23 @@ export function UnifiedChatsList() {
|
|||||||
return () => websocket.removeEventListener("message", handleWebSocketMessage);
|
return () => websocket.removeEventListener("message", handleWebSocketMessage);
|
||||||
}, [publicChats, loadLastMessages]);
|
}, [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 formatPublicChatMessage = (chatId: string): string => {
|
||||||
const lastMessage = lastMessages[chatId];
|
const lastMessage = lastMessages[chatId];
|
||||||
if (!lastMessage) {
|
if (!lastMessage) {
|
||||||
@@ -244,20 +263,23 @@ export function UnifiedChatsList() {
|
|||||||
<span slot="description" className="list-description">
|
<span slot="description" className="list-description">
|
||||||
{chat.lastMessage || "Нет сообщений"}
|
{chat.lastMessage || "Нет сообщений"}
|
||||||
</span>
|
</span>
|
||||||
|
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||||
<img
|
<img
|
||||||
src={chat.profile_picture || defaultAvatar}
|
src={chat.profile_picture || defaultAvatar}
|
||||||
alt={chat.username}
|
alt={chat.username}
|
||||||
slot="icon"
|
|
||||||
style={{
|
style={{
|
||||||
width: "40px",
|
width: "40px",
|
||||||
height: "40px",
|
height: "40px",
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
objectFit: "cover"
|
objectFit: "cover",
|
||||||
|
display: "block"
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<OnlineIndicator userId={chat.id} />
|
||||||
|
</div>
|
||||||
{chat.unreadCount > 0 && (
|
{chat.unreadCount > 0 && (
|
||||||
<mdui-badge slot="end-icon">
|
<mdui-badge slot="end-icon">
|
||||||
{chat.unreadCount}
|
{chat.unreadCount}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from "react";
|
|||||||
import { useAppState } from "@/pages/chat/state";
|
import { useAppState } from "@/pages/chat/state";
|
||||||
import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi";
|
import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi";
|
||||||
import type { User } from "@/core/types";
|
import type { User } from "@/core/types";
|
||||||
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
|
import { OnlineIndicator } from "../right/OnlineIndicator";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
import SearchBar from "@/core/components/SearchBar";
|
import SearchBar from "@/core/components/SearchBar";
|
||||||
|
|
||||||
@@ -51,6 +53,21 @@ export function UsernameSearch() {
|
|||||||
};
|
};
|
||||||
}, [searchQuery, user.authToken]);
|
}, [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) {
|
async function handleUserClick(searchUser: SearchUser) {
|
||||||
if (!user.authToken) return;
|
if (!user.authToken) return;
|
||||||
|
|
||||||
@@ -133,17 +150,23 @@ export function UsernameSearch() {
|
|||||||
onClick={() => handleUserClick(searchUser)}
|
onClick={() => handleUserClick(searchUser)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
<span slot="description" className="list-description">
|
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||||
{searchUser.online ? "В сети" : "Не в сети"}
|
|
||||||
</span>
|
|
||||||
<img
|
<img
|
||||||
src={searchUser.profile_picture || defaultAvatar}
|
src={searchUser.profile_picture || defaultAvatar}
|
||||||
alt={searchUser.username}
|
alt={searchUser.username}
|
||||||
slot="icon"
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "40px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
objectFit: "cover",
|
||||||
|
display: "block"
|
||||||
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<OnlineIndicator userId={searchUser.id} />
|
||||||
|
</div>
|
||||||
</mdui-list-item>
|
</mdui-list-item>
|
||||||
))}
|
))}
|
||||||
</mdui-list>
|
</mdui-list>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface ChatInputWrapperProps {
|
|||||||
onCloseEdit?: () => void;
|
onCloseEdit?: () => void;
|
||||||
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
|
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
|
||||||
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
|
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onTyping?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInputWrapper(
|
export function ChatInputWrapper(
|
||||||
@@ -35,7 +36,8 @@ export function ChatInputWrapper(
|
|||||||
onClearEdit,
|
onClearEdit,
|
||||||
onCloseEdit,
|
onCloseEdit,
|
||||||
onProvideFileAdder,
|
onProvideFileAdder,
|
||||||
messagePanelRef
|
messagePanelRef,
|
||||||
|
onTyping
|
||||||
}: ChatInputWrapperProps
|
}: ChatInputWrapperProps
|
||||||
) {
|
) {
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
@@ -91,6 +93,17 @@ export function ChatInputWrapper(
|
|||||||
setMessage(prev => prev + emoji);
|
setMessage(prev => prev + emoji);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function handleTyping() {
|
||||||
|
if (onTyping) {
|
||||||
|
onTyping();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleMessageChange(value: string) {
|
||||||
|
setMessage(value);
|
||||||
|
handleTyping();
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent | Event) {
|
async function handleSubmit(e: React.FormEvent | Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const hasText = Boolean(message.trim());
|
const hasText = Boolean(message.trim());
|
||||||
@@ -196,7 +209,7 @@ export function ChatInputWrapper(
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
text={message}
|
text={message}
|
||||||
rows={1}
|
rows={1}
|
||||||
onTextChange={(value) => setMessage(value)}
|
onTextChange={handleMessageChange}
|
||||||
onEnter={handleSubmit} />
|
onEnter={handleSubmit} />
|
||||||
<div className="buttons">
|
<div className="buttons">
|
||||||
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
|
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
|
||||||
|
|||||||
@@ -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 { useAppState } from "@/pages/chat/state";
|
||||||
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
|
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
|
||||||
import { ChatMessages } from "./ChatMessages";
|
import { ChatMessages } from "./ChatMessages";
|
||||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||||
import { ProfileDialog } from "../ProfileDialog";
|
import { ProfileDialog } from "@/pages/chat/ui/ProfileDialog";
|
||||||
import { setGlobalMessageHandler } from "@/core/websocket";
|
import { setGlobalMessageHandler } from "@/core/websocket";
|
||||||
import type { Message, WebSocketMessage } from "@/core/types";
|
import type { Message, WebSocketMessage } from "@/core/types";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity";
|
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 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 {
|
interface MessagePanelRendererProps {
|
||||||
panel: MessagePanel | null;
|
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 ? <TypingIndicator typingUsers={[]} /> : <OnlineStatus userId={recipientId} />;
|
||||||
|
} else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) {
|
||||||
|
content = <TypingIndicator typingUsers={otherTypingUsers} />;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||||
const { applyPendingPanel, chat, setProfileDialog } = useAppState();
|
const { applyPendingPanel, chat, setProfileDialog } = useAppState();
|
||||||
const messagePanelRef = useRef<HTMLDivElement>(null);
|
const messagePanelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -233,14 +262,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
|||||||
<div className="chat-header-info">
|
<div className="chat-header-info">
|
||||||
<div className="info-chat">
|
<div className="info-chat">
|
||||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||||
<p>
|
<ChatHeaderText panel={panel} />
|
||||||
<span className={`online-status ${panelState?.online ? "online" : ""}`}></span>
|
|
||||||
{panelState ? (
|
|
||||||
panelState.online ? "Online" : "Offline"
|
|
||||||
) : (
|
|
||||||
"Выберите чат, чтобы начать переписку"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{panel?.isDm() && (
|
{panel?.isDm() && (
|
||||||
<mdui-button-icon onClick={handleCallClick} icon="call--filled" />
|
<mdui-button-icon onClick={handleCallClick} icon="call--filled" />
|
||||||
@@ -315,6 +337,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
|||||||
</div>
|
</div>
|
||||||
</AnimatedOpacity>
|
</AnimatedOpacity>
|
||||||
|
|
||||||
|
|
||||||
<ChatInputWrapper
|
<ChatInputWrapper
|
||||||
onSendMessage={(text, files) => {
|
onSendMessage={(text, files) => {
|
||||||
panel.handleSendMessage(text, replyTo?.id, files);
|
panel.handleSendMessage(text, replyTo?.id, files);
|
||||||
@@ -354,6 +377,14 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
|||||||
}}
|
}}
|
||||||
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
|
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
|
||||||
messagePanelRef={messagePanelRef}
|
messagePanelRef={messagePanelRef}
|
||||||
|
onTyping={() => {
|
||||||
|
if (panel.isDm()) {
|
||||||
|
const dmPanel = panel as DMPanel;
|
||||||
|
dmPanel.handleTyping();
|
||||||
|
} else {
|
||||||
|
typingManager.sendTyping();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className={`online-indicator ${className}`}>
|
||||||
|
<div className="indicator-dot online"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={`online-status ${className}`}>
|
||||||
|
<div className={`status-dot ${status.online ? "online" : "offline"}`}></div>
|
||||||
|
<span className="status-text">
|
||||||
|
{status.online ? "В сети" : "Не в сети"}
|
||||||
|
</span>
|
||||||
|
{showLastSeen && !status.online && (
|
||||||
|
<span className="last-seen">
|
||||||
|
{formatLastSeen(status.lastSeen)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="typing-indicator">
|
||||||
|
<div className="typing-dots">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<span className="typing-text">{typingText}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ import { fetchUserProfile } from "@/core/api/profileApi";
|
|||||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||||
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||||
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
|
import { typingManager } from "@/core/typingManager";
|
||||||
|
|
||||||
export interface DMPanelData {
|
export interface DMPanelData {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -34,13 +36,25 @@ export class DMPanel extends MessagePanel {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRecipientId(): number | null {
|
||||||
|
return this.dmData?.userId || null;
|
||||||
|
}
|
||||||
|
|
||||||
async activate(): Promise<void> {
|
async activate(): Promise<void> {
|
||||||
// Don't load messages immediately during activation to prevent animation freeze
|
// Don't load messages immediately during activation to prevent animation freeze
|
||||||
// Messages will be loaded after the animation completes
|
// 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 {
|
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 {
|
clearMessages(): void {
|
||||||
@@ -254,6 +268,11 @@ export class DMPanel extends MessagePanel {
|
|||||||
|
|
||||||
// Reset for DM switching
|
// Reset for DM switching
|
||||||
reset(): void {
|
reset(): void {
|
||||||
|
// Unsubscribe from current recipient's status before switching
|
||||||
|
if (this.dmData?.userId) {
|
||||||
|
onlineStatusManager.unsubscribe(this.dmData.userId);
|
||||||
|
}
|
||||||
|
|
||||||
this.dmData = null;
|
this.dmData = null;
|
||||||
this.messagesLoaded = false;
|
this.messagesLoaded = false;
|
||||||
this.clearMessages();
|
this.clearMessages();
|
||||||
@@ -280,6 +299,13 @@ export class DMPanel extends MessagePanel {
|
|||||||
return this.dmData?.username || null;
|
return this.dmData?.username || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle typing in DM
|
||||||
|
handleTyping(): void {
|
||||||
|
if (this.dmData?.userId) {
|
||||||
|
typingManager.sendDmTyping(this.dmData.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions for localStorage
|
// Helper functions for localStorage
|
||||||
private getLastReadId(userId: number): number {
|
private getLastReadId(userId: number): number {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user