mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
Implement real-time online status and typing indicator
This commit is contained in:
@@ -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
@@ -530,4 +530,94 @@ export interface CallVideoToggleMessage extends CallSignalingMessage {
|
||||
export interface CallScreenShareToggleMessage extends CallSignalingMessage {
|
||||
type: "call_screen_share_toggle";
|
||||
data: CallScreenShareToggleData;
|
||||
}
|
||||
|
||||
// -----------
|
||||
// Online Status & Typing WebSocket Messages
|
||||
// -----------
|
||||
|
||||
export interface StatusUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "statusUpdate";
|
||||
data: {
|
||||
userId: number;
|
||||
online: boolean;
|
||||
lastSeen: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubscribeStatusWebSocketMessage extends WebSocketMessage {
|
||||
type: "subscribeStatus";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
userId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UnsubscribeStatusWebSocketMessage extends WebSocketMessage {
|
||||
type: "unsubscribeStatus";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
userId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "typing";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "stopTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DmTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopDmTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "stopDmTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Request types for sending typing/status messages
|
||||
export interface TypingRequest extends WebSocketMessage {
|
||||
type: "typing";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {};
|
||||
}
|
||||
|
||||
export interface StopTypingRequest extends WebSocketMessage {
|
||||
type: "stopTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {};
|
||||
}
|
||||
|
||||
export interface DmTypingRequest extends WebSocketMessage {
|
||||
type: "dmTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
recipientId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopDmTypingRequest extends WebSocketMessage {
|
||||
type: "stopDmTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
recipientId: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @fileoverview Typing indicator manager for real-time typing status
|
||||
* @description Handles typing indicators for public chat and DMs via WebSocket
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { request } from "./websocket";
|
||||
import type {
|
||||
TypingWebSocketMessage,
|
||||
StopTypingWebSocketMessage,
|
||||
DmTypingWebSocketMessage,
|
||||
StopDmTypingWebSocketMessage,
|
||||
TypingRequest,
|
||||
StopTypingRequest,
|
||||
DmTypingRequest,
|
||||
StopDmTypingRequest
|
||||
} from "./types";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
|
||||
/**
|
||||
* Manages typing indicators for public chat and DMs
|
||||
*/
|
||||
export class TypingManager {
|
||||
private authToken: string | null = null;
|
||||
private typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
private readonly TYPING_TIMEOUT = 3000; // 3 seconds
|
||||
|
||||
/**
|
||||
* Set the authentication token for WebSocket requests
|
||||
*/
|
||||
setAuthToken(token: string | null): void {
|
||||
this.authToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send typing indicator for public chat
|
||||
*/
|
||||
async sendTyping(): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: TypingRequest = {
|
||||
type: "typing",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.scheduleStopTyping("public");
|
||||
} catch (error) {
|
||||
console.error("Failed to send typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send stop typing indicator for public chat
|
||||
*/
|
||||
async sendStopTyping(): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: StopTypingRequest = {
|
||||
type: "stopTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.clearStopTypingTimeout("public");
|
||||
} catch (error) {
|
||||
console.error("Failed to send stop typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send typing indicator for DM
|
||||
*/
|
||||
async sendDmTyping(recipientId: number): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: DmTypingRequest = {
|
||||
type: "dmTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
recipientId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.scheduleStopDmTyping(recipientId);
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send stop typing indicator for DM
|
||||
*/
|
||||
async sendStopDmTyping(recipientId: number): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: StopDmTypingRequest = {
|
||||
type: "stopDmTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
recipientId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.clearStopTypingTimeout(`dm_${recipientId}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to send stop DM typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming typing indicator from WebSocket
|
||||
*/
|
||||
handleTyping(message: TypingWebSocketMessage): void {
|
||||
const { addTypingUser } = useAppState.getState();
|
||||
addTypingUser(message.data.userId, message.data.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stop typing indicator from WebSocket
|
||||
*/
|
||||
handleStopTyping(message: StopTypingWebSocketMessage): void {
|
||||
const { removeTypingUser } = useAppState.getState();
|
||||
removeTypingUser(message.data.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming DM typing indicator from WebSocket
|
||||
*/
|
||||
handleDmTyping(message: DmTypingWebSocketMessage): void {
|
||||
const { setDmTypingUser } = useAppState.getState();
|
||||
setDmTypingUser(message.data.userId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stop DM typing indicator from WebSocket
|
||||
*/
|
||||
handleStopDmTyping(message: StopDmTypingWebSocketMessage): void {
|
||||
const { setDmTypingUser } = useAppState.getState();
|
||||
setDmTypingUser(message.data.userId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic stop typing after timeout
|
||||
*/
|
||||
private scheduleStopTyping(context: string): void {
|
||||
this.clearStopTypingTimeout(context);
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
if (context === "public") {
|
||||
await this.sendStopTyping();
|
||||
}
|
||||
this.typingTimeouts.delete(context);
|
||||
}, this.TYPING_TIMEOUT);
|
||||
|
||||
this.typingTimeouts.set(context, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic stop DM typing after timeout
|
||||
*/
|
||||
private scheduleStopDmTyping(recipientId: number): void {
|
||||
const context = `dm_${recipientId}`;
|
||||
this.clearStopTypingTimeout(context);
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
await this.sendStopDmTyping(recipientId);
|
||||
this.typingTimeouts.delete(context);
|
||||
}, this.TYPING_TIMEOUT);
|
||||
|
||||
this.typingTimeouts.set(context, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stop typing timeout
|
||||
*/
|
||||
private clearStopTypingTimeout(context: string): void {
|
||||
const timeout = this.typingTimeouts.get(context);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.typingTimeouts.delete(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all timeouts
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.typingTimeouts.forEach(timeout => clearTimeout(timeout));
|
||||
this.typingTimeouts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const typingManager = new TypingManager();
|
||||
@@ -9,6 +9,8 @@ import { API_WS_BASE_URL } from "./config";
|
||||
import type { WebSocketMessage } from "./types";
|
||||
import { 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);
|
||||
|
||||
Reference in New Issue
Block a user