mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Merge branch 'feature/online-status'
This commit is contained in:
+236
-4
@@ -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
|
||||
@@ -502,13 +504,10 @@ async def edit_message(
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
if message.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
||||
|
||||
if not request.content.strip():
|
||||
raise HTTPException(status_code=400, detail="Message content cannot be empty")
|
||||
|
||||
message.content = request.content.strip()
|
||||
message.is_edited = True
|
||||
|
||||
@@ -639,7 +638,6 @@ async def add_dm_reaction(
|
||||
|
||||
# Broadcast reaction update to both participants
|
||||
try:
|
||||
from .messaging import messagingManager
|
||||
await messagingManager.broadcast({
|
||||
"type": "dmReactionUpdate",
|
||||
"data": {
|
||||
@@ -661,11 +659,19 @@ class MessaggingSocketManager:
|
||||
def __init__(self) -> None:
|
||||
self.connections: list[WebSocket] = []
|
||||
self.user_by_ws: dict[WebSocket, int] = {}
|
||||
self.online_users: set[int] = set()
|
||||
self.typing_users: dict[int, float] = {} # user_id -> timestamp
|
||||
self.dm_typing_users: dict[int, dict[int, float]] = {} # user_id -> {recipient_id -> timestamp}
|
||||
self.ws_subscriptions: dict[WebSocket, set[int]] = {} # websocket -> set of subscribed user_ids
|
||||
self._cleanup_task = None
|
||||
|
||||
async def send_error(self, websocket: WebSocket, type: str, e: HTTPException):
|
||||
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
|
||||
|
||||
async def handle_connection(self, websocket: WebSocket, db: Session):
|
||||
# Initialize subscriptions for this connection
|
||||
self.ws_subscriptions[websocket] = set()
|
||||
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
type = data["type"]
|
||||
@@ -687,6 +693,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 +1054,135 @@ class MessaggingSocketManager:
|
||||
await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "subscribeStatus":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
user_id_to_subscribe = int(data["data"]["userId"])
|
||||
self.ws_subscriptions[websocket].add(user_id_to_subscribe)
|
||||
|
||||
# Get current status of the user
|
||||
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
|
||||
if target_user:
|
||||
await websocket.send_json({
|
||||
"type": "statusUpdate",
|
||||
"data": {
|
||||
"userId": user_id_to_subscribe,
|
||||
"online": target_user.online,
|
||||
"lastSeen": target_user.last_seen.isoformat()
|
||||
}
|
||||
})
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "subscribeStatus",
|
||||
"data": {"status": "error", "error": "User not found"}
|
||||
})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "unsubscribeStatus":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
user_id_to_unsubscribe = int(data["data"]["userId"])
|
||||
self.ws_subscriptions[websocket].discard(user_id_to_unsubscribe)
|
||||
|
||||
await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "typing":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
self.typing_users[current_user.id] = time.time()
|
||||
|
||||
# Broadcast to all connected users
|
||||
await self.broadcast({
|
||||
"type": "typing",
|
||||
"data": {
|
||||
"userId": current_user.id,
|
||||
"username": current_user.username
|
||||
}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": "typing", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "stopTyping":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
if current_user.id in self.typing_users:
|
||||
del self.typing_users[current_user.id]
|
||||
|
||||
# Broadcast to all connected users
|
||||
await self.broadcast({
|
||||
"type": "stopTyping",
|
||||
"data": {
|
||||
"userId": current_user.id,
|
||||
"username": current_user.username
|
||||
}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "dmTyping":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
recipient_id = int(data["data"]["recipientId"])
|
||||
|
||||
if current_user.id not in self.dm_typing_users:
|
||||
self.dm_typing_users[current_user.id] = {}
|
||||
self.dm_typing_users[current_user.id][recipient_id] = time.time()
|
||||
|
||||
# Send only to recipient
|
||||
await self.send_to_user(recipient_id, {
|
||||
"type": "dmTyping",
|
||||
"data": {
|
||||
"userId": current_user.id,
|
||||
"username": current_user.username
|
||||
}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": "dmTyping", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "stopDmTyping":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
recipient_id = int(data["data"]["recipientId"])
|
||||
|
||||
if current_user.id in self.dm_typing_users and recipient_id in self.dm_typing_users[current_user.id]:
|
||||
del self.dm_typing_users[current_user.id][recipient_id]
|
||||
if not self.dm_typing_users[current_user.id]:
|
||||
del self.dm_typing_users[current_user.id]
|
||||
|
||||
# Send only to recipient
|
||||
await self.send_to_user(recipient_id, {
|
||||
"type": "stopDmTyping",
|
||||
"data": {
|
||||
"userId": current_user.id,
|
||||
"username": current_user.username
|
||||
}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": "stopDmTyping", "data": {"status": "ok"}})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
else:
|
||||
await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}})
|
||||
|
||||
@@ -1057,9 +1200,24 @@ class MessaggingSocketManager:
|
||||
except WebSocketDisconnect as e:
|
||||
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
|
||||
finally:
|
||||
# Cleanup connection
|
||||
self.connections.remove(websocket)
|
||||
if websocket in self.user_by_ws:
|
||||
user_id = self.user_by_ws[websocket]
|
||||
# Set user offline in DB
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user:
|
||||
user.online = False
|
||||
user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
# Remove from online users
|
||||
self.online_users.discard(user_id)
|
||||
# Broadcast status change
|
||||
await self.broadcast_status_change(user_id, False, user.last_seen.isoformat())
|
||||
del self.user_by_ws[websocket]
|
||||
# Cleanup subscriptions
|
||||
if websocket in self.ws_subscriptions:
|
||||
del self.ws_subscriptions[websocket]
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
for websocket in self.connections:
|
||||
@@ -1070,7 +1228,81 @@ class MessaggingSocketManager:
|
||||
if self.user_by_ws.get(websocket) == user_id:
|
||||
await websocket.send_json(message)
|
||||
|
||||
async def broadcast_status_change(self, user_id: int, online: bool, last_seen: str):
|
||||
"""Broadcast status change to all connections that are subscribed to this user"""
|
||||
message = {
|
||||
"type": "statusUpdate",
|
||||
"data": {
|
||||
"userId": user_id,
|
||||
"online": online,
|
||||
"lastSeen": last_seen
|
||||
}
|
||||
}
|
||||
|
||||
# Send to all connections that have this user in their subscriptions
|
||||
for websocket in self.connections:
|
||||
if websocket in self.ws_subscriptions and user_id in self.ws_subscriptions[websocket]:
|
||||
await websocket.send_json(message)
|
||||
|
||||
async def cleanup_stale_typing_indicators(self):
|
||||
"""Periodically cleanup typing indicators that haven't been updated in 3+ seconds"""
|
||||
while True:
|
||||
try:
|
||||
current_time = time.time()
|
||||
stale_threshold = 3.0 # 3 seconds
|
||||
|
||||
# Cleanup public chat typing indicators
|
||||
stale_public_typing = [
|
||||
user_id for user_id, timestamp in self.typing_users.items()
|
||||
if current_time - timestamp > stale_threshold
|
||||
]
|
||||
|
||||
for user_id in stale_public_typing:
|
||||
del self.typing_users[user_id]
|
||||
# Broadcast stop typing
|
||||
await self.broadcast({
|
||||
"type": "stopTyping",
|
||||
"data": {
|
||||
"userId": user_id,
|
||||
"username": "Unknown" # We don't have username here, frontend will handle
|
||||
}
|
||||
})
|
||||
|
||||
# Cleanup DM typing indicators
|
||||
stale_dm_typing = []
|
||||
for user_id, recipients in self.dm_typing_users.items():
|
||||
for recipient_id, timestamp in list(recipients.items()):
|
||||
if current_time - timestamp > stale_threshold:
|
||||
stale_dm_typing.append((user_id, recipient_id))
|
||||
|
||||
for user_id, recipient_id in stale_dm_typing:
|
||||
if user_id in self.dm_typing_users and recipient_id in self.dm_typing_users[user_id]:
|
||||
del self.dm_typing_users[user_id][recipient_id]
|
||||
if not self.dm_typing_users[user_id]:
|
||||
del self.dm_typing_users[user_id]
|
||||
# Send stop typing to recipient
|
||||
await self.send_to_user(recipient_id, {
|
||||
"type": "stopDmTyping",
|
||||
"data": {
|
||||
"userId": user_id,
|
||||
"username": "Unknown" # We don't have username here, frontend will handle
|
||||
}
|
||||
})
|
||||
|
||||
# Wait 1 second before next cleanup
|
||||
await asyncio.sleep(1.0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in typing cleanup task: {e}")
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
def start_cleanup_task(self):
|
||||
"""Start the cleanup task if not already running"""
|
||||
if self._cleanup_task is None or self._cleanup_task.done():
|
||||
self._cleanup_task = asyncio.create_task(self.cleanup_stale_typing_indicators())
|
||||
|
||||
messagingManager = MessaggingSocketManager()
|
||||
# Start the cleanup task
|
||||
messagingManager.start_cleanup_task()
|
||||
|
||||
@router.websocket("/chat/ws")
|
||||
async def chat_websocket(
|
||||
|
||||
@@ -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";
|
||||
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,232 @@
|
||||
/**
|
||||
* @fileoverview Typing indicator manager for real-time typing status
|
||||
* @description Handles typing indicators for public chat and DMs via WebSocket
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { request } from "./websocket";
|
||||
import type {
|
||||
TypingWebSocketMessage,
|
||||
StopTypingWebSocketMessage,
|
||||
DmTypingWebSocketMessage,
|
||||
StopDmTypingWebSocketMessage,
|
||||
TypingRequest,
|
||||
StopTypingRequest,
|
||||
DmTypingRequest,
|
||||
StopDmTypingRequest
|
||||
} from "./types";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
|
||||
/**
|
||||
* Manages typing indicators for public chat and DMs
|
||||
*/
|
||||
export class TypingManager {
|
||||
private authToken: string | null = null;
|
||||
private typingTimeouts: Map<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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop typing for public chat (called when message is sent)
|
||||
*/
|
||||
async stopTypingOnMessage(): Promise<void> {
|
||||
this.clearStopTypingTimeout("public");
|
||||
await this.sendStopTyping();
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop DM typing (called when message is sent)
|
||||
*/
|
||||
async stopDmTypingOnMessage(recipientId: number): Promise<void> {
|
||||
this.clearStopTypingTimeout(`dm_${recipientId}`);
|
||||
await this.sendStopDmTyping(recipientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all timeouts
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.typingTimeouts.forEach(timeout => clearTimeout(timeout));
|
||||
this.typingTimeouts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const typingManager = new TypingManager();
|
||||
@@ -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);
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Typing indicator styles
|
||||
|
||||
// Emoji Menu Styles
|
||||
.emoji-menu {
|
||||
$transition: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@@ -156,7 +156,6 @@
|
||||
height: 45px;
|
||||
border-radius: 20%;
|
||||
object-fit: cover;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
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 {
|
||||
display: flex;
|
||||
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 "callWindow";
|
||||
@use "profile-dialog";
|
||||
@use "typing-indicators";
|
||||
@@ -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<number, {online: boolean, lastSeen: string}>;
|
||||
typingUsers: Map<number, string>; // userId -> username
|
||||
dmTypingUsers: Map<number, boolean>;
|
||||
}
|
||||
|
||||
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<AppState>((set, get) => ({
|
||||
@@ -146,7 +157,10 @@ export const useAppState = create<AppState>((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<AppState>((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<AppState>((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<AppState>((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<AppState>((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
|
||||
}
|
||||
};
|
||||
})
|
||||
}));
|
||||
@@ -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 && (
|
||||
<div className="online-status-section">
|
||||
<span className={`online-indicator ${currentData.online ? "" : "offline"}`} />
|
||||
<span className="status-text">
|
||||
{currentData.online ? "Онлайн" : "Оффлайн"}
|
||||
</span>
|
||||
<OnlineStatus userId={currentData.userId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<span slot="description" className="list-description">
|
||||
{chat.lastMessage || "Нет сообщений"}
|
||||
</span>
|
||||
<img
|
||||
src={chat.profile_picture || defaultAvatar}
|
||||
alt={chat.username}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||
<img
|
||||
src={chat.profile_picture || defaultAvatar}
|
||||
alt={chat.username}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
display: "block"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<OnlineIndicator userId={chat.id} />
|
||||
</div>
|
||||
{chat.unreadCount > 0 && (
|
||||
<mdui-badge slot="end-icon">
|
||||
{chat.unreadCount}
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
<span slot="description" className="list-description">
|
||||
{searchUser.online ? "В сети" : "Не в сети"}
|
||||
</span>
|
||||
<img
|
||||
src={searchUser.profile_picture || defaultAvatar}
|
||||
alt={searchUser.username}
|
||||
slot="icon"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||
<img
|
||||
src={searchUser.profile_picture || defaultAvatar}
|
||||
alt={searchUser.username}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
display: "block"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<OnlineIndicator userId={searchUser.id} />
|
||||
</div>
|
||||
</mdui-list-item>
|
||||
))}
|
||||
</mdui-list>
|
||||
|
||||
@@ -20,6 +20,8 @@ interface ChatInputWrapperProps {
|
||||
onCloseEdit?: () => void;
|
||||
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
|
||||
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
|
||||
onTyping?: () => void;
|
||||
onStopTyping?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputWrapper(
|
||||
@@ -35,7 +37,9 @@ export function ChatInputWrapper(
|
||||
onClearEdit,
|
||||
onCloseEdit,
|
||||
onProvideFileAdder,
|
||||
messagePanelRef
|
||||
messagePanelRef,
|
||||
onTyping,
|
||||
onStopTyping
|
||||
}: ChatInputWrapperProps
|
||||
) {
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -91,6 +95,17 @@ export function ChatInputWrapper(
|
||||
setMessage(prev => prev + emoji);
|
||||
};
|
||||
|
||||
function handleTyping() {
|
||||
if (onTyping) {
|
||||
onTyping();
|
||||
}
|
||||
};
|
||||
|
||||
function handleMessageChange(value: string) {
|
||||
setMessage(value);
|
||||
handleTyping();
|
||||
};
|
||||
|
||||
async function handleSubmit(e: React.FormEvent | Event) {
|
||||
e.preventDefault();
|
||||
const hasText = Boolean(message.trim());
|
||||
@@ -111,6 +126,8 @@ export function ChatInputWrapper(
|
||||
setMessage("");
|
||||
setAttachmentsVisible(false);
|
||||
if (onClearReply) onClearReply();
|
||||
// Stop typing indicator when message is sent
|
||||
if (onStopTyping) onStopTyping();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -196,7 +213,7 @@ export function ChatInputWrapper(
|
||||
autoComplete="off"
|
||||
text={message}
|
||||
rows={1}
|
||||
onTextChange={(value) => setMessage(value)}
|
||||
onTextChange={handleMessageChange}
|
||||
onEnter={handleSubmit} />
|
||||
<div className="buttons">
|
||||
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
|
||||
|
||||
@@ -396,6 +396,8 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
if (userProfile) {
|
||||
setProfileDialog({
|
||||
...userProfile,
|
||||
userId: userProfile.id,
|
||||
memberSince: userProfile.created_at,
|
||||
isOwnProfile: false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ? <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) {
|
||||
const { applyPendingPanel, chat, setProfileDialog } = useAppState();
|
||||
const messagePanelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -233,14 +262,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||
<p>
|
||||
<span className={`online-status ${panelState?.online ? "online" : ""}`}></span>
|
||||
{panelState ? (
|
||||
panelState.online ? "Online" : "Offline"
|
||||
) : (
|
||||
"Выберите чат, чтобы начать переписку"
|
||||
)}
|
||||
</p>
|
||||
<ChatHeaderText panel={panel} />
|
||||
</div>
|
||||
{panel?.isDm() && (
|
||||
<mdui-button-icon onClick={handleCallClick} icon="call--filled" />
|
||||
@@ -315,6 +337,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
</div>
|
||||
</AnimatedOpacity>
|
||||
|
||||
|
||||
<ChatInputWrapper
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
@@ -354,6 +377,22 @@ 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();
|
||||
}
|
||||
}}
|
||||
onStopTyping={() => {
|
||||
if (panel.isDm()) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
typingManager.stopDmTypingOnMessage(dmPanel.getRecipientId()!);
|
||||
} else {
|
||||
typingManager.stopTypingOnMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,53 @@
|
||||
/**
|
||||
* @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;
|
||||
showLastSeen?: boolean;
|
||||
}
|
||||
|
||||
export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps) {
|
||||
const { chat, user } = useAppState();
|
||||
const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : chat.onlineStatuses.get(userId);
|
||||
|
||||
function 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">
|
||||
<div className={`status-dot ${status?.online ? "online" : "offline"}`}></div>
|
||||
<span className="status-text">
|
||||
{!status ? "Загрузка..." : status?.online ? "В сети" : "Не в сети"}
|
||||
</span>
|
||||
{showLastSeen && status && !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 { 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<void> {
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user