Implement real-time online status and typing indicator

This commit is contained in:
2025-10-18 22:50:13 +03:00
Unverified
parent 0ecf324028
commit 1e86b9fc84
20 changed files with 1191 additions and 57 deletions
+238
View File
@@ -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:
@@ -1069,8 +1233,82 @@ class MessaggingSocketManager:
for websocket in self.connections: for websocket in self.connections:
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(
+152
View File
@@ -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();
+90
View File
@@ -530,4 +530,94 @@ export interface CallVideoToggleMessage extends CallSignalingMessage {
export interface CallScreenShareToggleMessage extends CallSignalingMessage { 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;
};
} }
+216
View File
@@ -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();
+15
View File
@@ -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;
}
}
+2 -1
View File
@@ -9,4 +9,5 @@
@use "settings-dialog"; @use "settings-dialog";
@use "animations"; @use "animations";
@use "callWindow"; @use "callWindow";
@use "profile-dialog"; @use "profile-dialog";
@use "typing-indicators";
+71 -2
View File
@@ -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
}
};
})
})); }));
+19 -5
View File
@@ -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>
<img <div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
src={chat.profile_picture || defaultAvatar} <img
alt={chat.username} src={chat.profile_picture || defaultAvatar}
slot="icon" alt={chat.username}
style={{ style={{
width: "40px", width: "40px",
height: "40px", height: "40px",
borderRadius: "50%", borderRadius: "50%",
objectFit: "cover" objectFit: "cover",
}} display: "block"
onError={(e) => { }}
(e.target as HTMLImageElement).src = defaultAvatar; onError={(e) => {
}} (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 ? "В сети" : "Не в сети"} <img
</span> src={searchUser.profile_picture || defaultAvatar}
<img alt={searchUser.username}
src={searchUser.profile_picture || defaultAvatar} style={{
alt={searchUser.username} width: "40px",
slot="icon" height: "40px",
onError={(e) => { borderRadius: "50%",
(e.target as HTMLImageElement).src = defaultAvatar; objectFit: "cover",
}} display: "block"
/> }}
onError={(e) => {
(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 {