friends cursor broadcast system (?)

This commit is contained in:
2025-12-16 00:41:03 +08:00
parent 6812e003ea
commit 1325f4f879
4 changed files with 128 additions and 11 deletions

View File

@@ -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],

View File

@@ -6,5 +6,8 @@ export type AuthenticatedSocket = BaseSocket<
DefaultEventsMap, // ClientToServerEvents
DefaultEventsMap, // ServerToClientEvents
DefaultEventsMap, // InterServerEvents
{ user?: AuthenticatedUser }
{
user?: AuthenticatedUser;
friends?: Set<string>; // Set of friend user IDs
}
>;

View File

@@ -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})`,
// 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.logger.debug(`Payload: ${JSON.stringify(data, 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);
}
}
}
}
}

View File

@@ -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],
})