From 1325f4f8795e659cac4c5464d9d7d2840b9b1295 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Tue, 16 Dec 2025 00:41:03 +0800 Subject: [PATCH] friends cursor broadcast system (?) --- src/friends/friends.module.ts | 9 ++- src/types/socket.d.ts | 5 +- src/ws/state/state.gateway.ts | 120 ++++++++++++++++++++++++++++++++-- src/ws/ws.module.ts | 5 +- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/src/friends/friends.module.ts b/src/friends/friends.module.ts index d558230..d19e253 100644 --- a/src/friends/friends.module.ts +++ b/src/friends/friends.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { FriendsController } from './friends.controller'; import { FriendsService } from './friends.service'; import { DatabaseModule } from '../database/database.module'; @@ -7,7 +7,12 @@ import { UsersModule } from '../users/users.module'; import { WsModule } from '../ws/ws.module'; @Module({ - imports: [DatabaseModule, AuthModule, UsersModule, WsModule], + imports: [ + DatabaseModule, + AuthModule, + UsersModule, + forwardRef(() => WsModule), + ], controllers: [FriendsController], providers: [FriendsService], exports: [FriendsService], diff --git a/src/types/socket.d.ts b/src/types/socket.d.ts index 04dde24..321def7 100644 --- a/src/types/socket.d.ts +++ b/src/types/socket.d.ts @@ -6,5 +6,8 @@ export type AuthenticatedSocket = BaseSocket< DefaultEventsMap, // ClientToServerEvents DefaultEventsMap, // ServerToClientEvents DefaultEventsMap, // InterServerEvents - { user?: AuthenticatedUser } + { + user?: AuthenticatedUser; + friends?: Set; // Set of friend user IDs + } >; diff --git a/src/ws/state/state.gateway.ts b/src/ws/state/state.gateway.ts index 27f2fde..83dbed8 100644 --- a/src/ws/state/state.gateway.ts +++ b/src/ws/state/state.gateway.ts @@ -1,4 +1,4 @@ -import { Logger } from '@nestjs/common'; +import { Logger, Inject, forwardRef } from '@nestjs/common'; import { OnGatewayConnection, OnGatewayDisconnect, @@ -14,7 +14,10 @@ import type { AuthenticatedSocket } from '../../types/socket'; import { AuthService } from '../../auth/auth.service'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { CursorPositionDto } from '../dto/cursor-position.dto'; -import { FriendRequestWithRelations } from '../../friends/friends.service'; +import { + FriendRequestWithRelations, + FriendsService, +} from '../../friends/friends.service'; const WS_EVENT = { CURSOR_REPORT_POSITION: 'cursor-report-position', @@ -22,6 +25,7 @@ const WS_EVENT = { FRIEND_REQUEST_ACCEPTED: 'friend-request-accepted', FRIEND_REQUEST_DENIED: 'friend-request-denied', UNFRIENDED: 'unfriended', + FRIEND_CURSOR_POSITION: 'friend-cursor-position', } as const; @WebSocketGateway({ @@ -41,6 +45,8 @@ export class StateGateway constructor( private readonly authService: AuthService, private readonly jwtVerificationService: JwtVerificationService, + @Inject(forwardRef(() => FriendsService)) + private readonly friendsService: FriendsService, ) {} afterInit() { @@ -83,6 +89,10 @@ export class StateGateway const user = await this.authService.syncUserFromToken(client.data.user); this.userSocketMap.set(user.id, client.id); + // Initialize friends cache + const friends = await this.friendsService.getFriends(user.id); + client.data.friends = new Set(friends.map((f) => f.friendId)); + const { sockets } = this.io.sockets; this.logger.log( `Client id: ${client.id} connected (user: ${payload.sub})`, @@ -124,10 +134,45 @@ export class StateGateway throw new WsException('Unauthorized'); } - this.logger.log( - `Message received from client id: ${client.id} (user: ${user.keycloakSub})`, - ); - this.logger.debug(`Payload: ${JSON.stringify(data, null, 0)}`); + // Get the user ID from the userSocketMap (keycloakSub -> userId map is handled implicitly by connection logic but let's be safe and get it from map iteration or better store userId in client.data) + // Actually we stored user.id -> client.id in userSocketMap. But we don't have direct access to user.id from client.data.user (it has keycloakSub). + // Let's improve this by finding the userId. The user is already synced in handleConnection. + // However, for efficiency, let's reverse lookup or better yet, assume we can get userId. + // In handleConnection we did: const user = await this.authService.syncUserFromToken(client.data.user); this.userSocketMap.set(user.id, client.id); + // So we know the user.id is in the map. + + let currentUserId: string | undefined; + for (const [uid, sid] of this.userSocketMap.entries()) { + if (sid === client.id) { + currentUserId = uid; + break; + } + } + + if (!currentUserId) { + this.logger.warn(`Could not find user ID for client ${client.id}`); + return; + } + + // Broadcast to online friends + const friends = client.data.friends; + if (friends) { + for (const friendId of friends) { + const friendSocketId = this.userSocketMap.get(friendId); + if (friendSocketId) { + const payload = { + userId: currentUserId, + position: data, + }; + this.logger.debug( + `Sending friend cursor position to user ${friendId}: ${JSON.stringify(payload, null, 0)}`, + ); + this.io + .to(friendSocketId) + .emit(WS_EVENT.FRIEND_CURSOR_POSITION, payload); + } + } + } } emitFriendRequestReceived( @@ -158,6 +203,32 @@ export class StateGateway ) { const socketId = this.userSocketMap.get(userId); if (socketId) { + // Update cache for the user accepting (userId here is the sender of the original request) + // Wait, in friends.controller: acceptFriendRequest returns the request. + // emitFriendRequestAccepted is called with friendRequest.senderId (the one who sent the request). + // The one who accepted is friendRequest.receiverId. + + // We need to update cache for BOTH users if they are online. + + // 1. Update cache for the user who sent the request (userId / friendRequest.senderId) + const senderSocket = this.io.sockets.sockets.get( + socketId, + ) as AuthenticatedSocket; + if (senderSocket && senderSocket.data.friends) { + senderSocket.data.friends.add(friendRequest.receiverId); + } + + // 2. Update cache for the user who accepted the request (friendRequest.receiverId) + const receiverSocketId = this.userSocketMap.get(friendRequest.receiverId); + if (receiverSocketId) { + const receiverSocket = this.io.sockets.sockets.get( + receiverSocketId, + ) as AuthenticatedSocket; + if (receiverSocket && receiverSocket.data.friends) { + receiverSocket.data.friends.add(friendRequest.senderId); + } + } + this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_ACCEPTED, { id: friendRequest.id, friend: { @@ -199,10 +270,47 @@ export class StateGateway emitUnfriended(userId: string, friendId: string) { const socketId = this.userSocketMap.get(userId); if (socketId) { + // Update cache for the user being unfriended (userId) + // Wait, emitUnfriended is called with (friendId, user.id) in controller. + // So userId here is the friendId (the one being removed from friend list of the initiator). + // friendId here is the initiator (user.id). + + // We need to update cache for BOTH users. + + // 1. Update cache for the user receiving the notification (userId) + const socket = this.io.sockets.sockets.get( + socketId, + ) as AuthenticatedSocket; + if (socket && socket.data.friends) { + socket.data.friends.delete(friendId); + } + + // 2. Update cache for the user initiating the unfriend (friendId) + const initiatorSocketId = this.userSocketMap.get(friendId); + if (initiatorSocketId) { + const initiatorSocket = this.io.sockets.sockets.get( + initiatorSocketId, + ) as AuthenticatedSocket; + if (initiatorSocket && initiatorSocket.data.friends) { + initiatorSocket.data.friends.delete(userId); + } + } + this.io.to(socketId).emit(WS_EVENT.UNFRIENDED, { friendId, }); this.logger.debug(`Emitted unfriended notification to user ${userId}`); + } else { + // If the notified user is offline, we still need to update the initiator's cache if they are online + const initiatorSocketId = this.userSocketMap.get(friendId); + if (initiatorSocketId) { + const initiatorSocket = this.io.sockets.sockets.get( + initiatorSocketId, + ) as AuthenticatedSocket; + if (initiatorSocket && initiatorSocket.data.friends) { + initiatorSocket.data.friends.delete(userId); + } + } } } } diff --git a/src/ws/ws.module.ts b/src/ws/ws.module.ts index 8d1f410..3158cd3 100644 --- a/src/ws/ws.module.ts +++ b/src/ws/ws.module.ts @@ -1,9 +1,10 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { StateGateway } from './state/state.gateway'; import { AuthModule } from '../auth/auth.module'; +import { FriendsModule } from '../friends/friends.module'; @Module({ - imports: [AuthModule], + imports: [AuthModule, forwardRef(() => FriendsModule)], providers: [StateGateway], exports: [StateGateway], })