Skip to content

Commit

Permalink
manage online status with web socket (#244)
Browse files Browse the repository at this point in the history
- online | offline を管理するようにしました
- login 時には 全ユーザの状態を取得できます
- online-status event で、他のユーザのstatus変化が取得できます

TODO:
- pong 状態管理
  • Loading branch information
kotto5 authored Feb 7, 2024
1 parent 8120fcd commit 16eaf16
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 41 deletions.
1 change: 0 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { BanModule } from './room/ban/ban.module';
import { MuteModule } from './room/mute/mute.module';
import { RoomModule } from './room/room.module';
import { UserModule } from './user/user.module';

@Module({
imports: [
UserModule,
Expand Down
13 changes: 12 additions & 1 deletion backend/src/chat/chat.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { RoomMuteEvent } from 'src/common/events/room-mute.event';
import { RoomUnmuteEvent } from 'src/common/events/room-unmute.event';
import { RoomLeftEvent } from 'src/common/events/room-left.event';
import { RoomUpdateRoleEvent } from 'src/common/events/room-update-role.event';
import { ChatService } from './chat.service';
import { ChatService, UserStatus } from './chat.service';
import { MuteService } from 'src/room/mute/mute.service';
import { CreateMessageDto } from './dto/create-message.dto';
import { MessageEntity } from './entities/message.entity';
Expand Down Expand Up @@ -155,6 +155,17 @@ export class ChatGateway {
}
}

@OnEvent('online-status')
handleChangeOnlineStatus(
event: {
userId: number;
status: UserStatus;
}[],
) {
this.chatService.handleChangeOnlineStatus(event);
this.server.emit('online-status', event);
}

@OnEvent('room.enter', { async: true })
async handleEnter(event: RoomEnteredEvent) {
await this.chatService.addUserToRoom(event.roomId, event.userId);
Expand Down
50 changes: 48 additions & 2 deletions backend/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { UserService } from 'src/user/user.service';
import { CreateMessageDto } from './dto/create-message.dto';
import { PublicUserEntity } from './entities/message.entity';

export enum UserStatus {
Offline = 0b0,
Online = 0b1,
}

@Injectable()
@WebSocketGateway()
export class ChatService {
Expand All @@ -24,9 +29,10 @@ export class ChatService {

// Map<User.id, Socket>
private clients = new Map<User['id'], Socket>();
private users = new Map<Socket['id'], PublicUserEntity>();
// key: inviter, value: invitee
private users = new Map<Socket['id'], PublicUserEntity>();
private invite = new Map<User['id'], User['id']>();
private statuses = new Map<User['id'], UserStatus>();

getUser(client: Socket) {
return this.users.get(client.id);
Expand All @@ -52,6 +58,7 @@ export class ChatService {
removeClient(client: Socket) {
const user = this.users.get(client.id);
if (user) {
this.statuses.delete(user.id);
this.clients.delete(user.id);
this.users.delete(client.id);
this.removeInvite(user.id);
Expand Down Expand Up @@ -134,7 +141,7 @@ export class ChatService {
roomId;
event;
data;
// TOOD: send to room
// TODO: send to room
// this.server.to(roomId.toString()).emit(event, data);
}

Expand Down Expand Up @@ -166,15 +173,54 @@ export class ChatService {
},
});
rooms.forEach((room) => this.addUserToRoom(room.id, user.id));
this.statuses.set(user.id, UserStatus.Online);
client.emit('online-status', this.getUserStatuses());
client.broadcast.emit('online-status', [
{ userId: user.id, status: UserStatus.Online },
]);
} catch (error) {
console.log(error);
}
}

handleChangeOnlineStatus(
event: {
userId: number;
status: UserStatus;
}[],
) {
event.forEach((e) => {
const state = this.statuses[e.userId]
? (this.statuses[e.userId] |= e.status)
: e.status;
this.statuses.set(e.userId, state);
if (this.statuses[e.userId] === UserStatus.Offline) {
this.statuses.delete(e.userId);
}
});
}

handleDisconnect(client: Socket) {
const emitData = {
userId: this.getUserId(client),
status: UserStatus.Offline,
};
if (emitData.userId) {
client.broadcast.emit('online-status', [emitData]);
}
this.removeClient(client);
}

getUserStatuses(): {
userId: number;
status: UserStatus;
}[] {
return Array.from(this.statuses).map(([userId, status]) => ({
userId,
status,
}));
}

private async expectNotBlockedBy(blockerId: number, userId: number) {
const blockedBy = await this.prisma.user
.findFirstOrThrow({
Expand Down
1 change: 1 addition & 0 deletions backend/src/events/events.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export class EventsGateway implements OnGatewayDisconnect {

handleDisconnect(client: Socket) {
this.logger.log(`disconnect: ${client.id} `);

const roomId = client.handshake.query['game_id'] as string;
client.leave(roomId);
delete this.users[client.id];
Expand Down
141 changes: 104 additions & 37 deletions backend/test/online-status.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Socket, io } from 'socket.io-client';
import { AppModule } from 'src/app.module';
import { TestApp } from './utils/app';
import { TestApp, UserEntityWithAccessToken } from './utils/app';
import { expectOnlineStatusResponse } from './utils/matcher';
import { UserStatus } from 'src/chat/chat.service';

type UserAndSocket = {
user: UserEntityWithAccessToken;
ws: Socket;
};

async function createNestApp(): Promise<INestApplication> {
const moduleFixture: TestingModule = await Test.createTestingModule({
Expand All @@ -15,8 +22,8 @@ async function createNestApp(): Promise<INestApplication> {

describe('ChatGateway and ChatController (e2e)', () => {
let app: TestApp;
let user1;
let user2;
let user1: UserEntityWithAccessToken;
let user2: UserEntityWithAccessToken;

beforeAll(async () => {
//app = await initializeApp();
Expand All @@ -43,47 +50,107 @@ describe('ChatGateway and ChatController (e2e)', () => {
await app.close();
});

const connect = (ws: Socket) => {
return new Promise<void>((resolve) => {
ws.on('connect', () => {
resolve();
describe('online status (login logoff) ', () => {
let userAndSockets: UserAndSocket[];

beforeAll(() => {
const users = [user1, user2];
userAndSockets = users.map((u) => {
const ws = io('http://localhost:3000/chat', {
extraHeaders: {
cookie: `token=${u.accessToken}`,
},
autoConnect: false,
});
return { user: u, ws };
});
});
};

describe('online status', () => {
let onlineUser;
let onlineUserSocket;
let offlineUser;

beforeAll(async () => {
onlineUser = user1;
onlineUserSocket = io('ws://localhost:3000/chat', {
extraHeaders: { cookie: 'token=' + onlineUser.accessToken },
afterEach(() => {
userAndSockets.forEach((u) => {
u.ws.removeAllListeners(); // otherwise, the listeners are accumulated
u.ws.disconnect();
});
await connect(onlineUserSocket);
offlineUser = user2;
});
afterAll(() => {
onlineUserSocket.close();
describe('when I log in', () => {
it('should receive the online status of me', (done) => {
const us = userAndSockets[0];
us.ws.on('online-status', (users) => {
expectOnlineStatusResponse(users);
expect(users).toHaveLength(1);
expect(users[0].userId).toBe(us.user.id);
expect(users[0].status).toBe(UserStatus.Online);
done();
});
us.ws.connect();
});
});

it('online user should be true (online)', async () => {
const res = await app
.isOnline(onlineUser.id, onlineUser.accessToken)
.expect(200);
const body = res.body;
expect(body.isOnline).toEqual(true);
});
it('offline user should be false (offline)', async () => {
const res = await app
.isOnline(offlineUser.id, offlineUser.accessToken)
.expect(200);
const body = res.body;
expect(body.isOnline).toEqual(false);
describe('when other user logs in', () => {
let firstLoginUser: UserAndSocket;
let secondLoginUser: UserAndSocket;

beforeAll(() => {
firstLoginUser = userAndSockets[0];
secondLoginUser = userAndSockets[1];
const myLogin = new Promise<void>((resolve) => {
firstLoginUser.ws.once('online-status', () => {
// it is my own online status
resolve();
});
});
firstLoginUser.ws.connect();
return myLogin;
});

it('should receive the online status of the other user', (done) => {
firstLoginUser.ws.on('online-status', (users) => {
expectOnlineStatusResponse(users);
expect(users).toHaveLength(1);
expect(users[0].userId).toBe(secondLoginUser.user.id);
expect(users[0].status).toBe(UserStatus.Online);
done();
});
secondLoginUser.ws.connect();
});
});
it('check online status with invalid access token should be unauthorized', async () => {
await app.isOnline(onlineUser.id, '').expect(401);

describe('when other user logs out', () => {
let firstLoginUser: UserAndSocket;
let secondLoginUser: UserAndSocket;

beforeAll(async () => {
firstLoginUser = userAndSockets[0];
secondLoginUser = userAndSockets[1];
const myLogin = new Promise<void>((resolve) => {
firstLoginUser.ws.once('online-status', () => {
// it is my own online status
resolve();
});
});
firstLoginUser.ws.connect();
await myLogin;
return myLogin.then(() => {
const otherLogin = new Promise<void>((resolve) => {
firstLoginUser.ws.once('online-status', () => {
// it is the other user's online status
resolve();
});
});
secondLoginUser.ws.connect();
return otherLogin;
});
});

it('should receive the offline status of the other user', (done) => {
firstLoginUser.ws.on('online-status', (users) => {
expectOnlineStatusResponse(users);
expect(users).toHaveLength(1);
expect(users[0].userId).toBe(secondLoginUser.user.id);
expect(users[0].status).toBe(UserStatus.Offline);
done();
});
secondLoginUser.ws.disconnect();
});
});
});
});
12 changes: 12 additions & 0 deletions backend/test/utils/matcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as fs from 'fs';
import { UserStatus } from 'src/chat/chat.service';
import * as request from 'supertest';

export const expectRoomWithUsers = (room) => {
Expand Down Expand Up @@ -126,3 +127,14 @@ export function expectPostGenerateTwoFactorAuthenticationSecretResponse(
};
expect(res.body).toEqual(expected);
}

export function expectOnlineStatusResponse(
users: { userId: number; status: UserStatus }[],
) {
type User = { userId: number; status: UserStatus };
const expected: User[] = users.map(() => ({
userId: expect.any(Number),
status: expect.any(Number),
}));
expect(users).toEqual(expected);
}

0 comments on commit 16eaf16

Please sign in to comment.