From 735cbbc0d9a75182847800191ebc2ddf2d1c0a09 Mon Sep 17 00:00:00 2001 From: lim396 <90199432+lim396@users.noreply.github.com> Date: Tue, 16 Jan 2024 16:57:00 +0900 Subject: [PATCH] Feat/notification of kicks to client (#210) [backend] * Add test to ensure client receives notification when kicked * Notify client on kick [frontend] * Add handling of kick notification --- backend/src/chat/chat.gateway.ts | 8 +++ backend/src/chat/chat.service.ts | 1 - backend/test/chat-gateway.e2e-spec.ts | 47 +++++++++++++ frontend/app/room/[id]/sidebar-item.tsx | 92 +++++++++++++++---------- 4 files changed, 111 insertions(+), 37 deletions(-) diff --git a/backend/src/chat/chat.gateway.ts b/backend/src/chat/chat.gateway.ts index b29bdde6..0edadb51 100644 --- a/backend/src/chat/chat.gateway.ts +++ b/backend/src/chat/chat.gateway.ts @@ -12,6 +12,8 @@ import { ChatService } from './chat.service'; import { CreateDirectMessageDto } from './dto/create-direct-message.dto'; import { CreateMessageDto } from './dto/create-message.dto'; import { MessageEntity } from './entities/message.entity'; +import { RoomLeftEvent } from 'src/common/events/room-left.event'; +import { OnEvent } from '@nestjs/event-emitter'; //type PrivateMessage = { // conversationId: string; @@ -103,6 +105,12 @@ export class ChatGateway { ); } + @OnEvent('room.leave', { async: true }) + async handleLeave(event: RoomLeftEvent) { + this.server.in(event.roomId.toString()).emit('left-room', event.userId); + await this.chatService.removeUserFromRoom(event); + } + async handleConnection(@ConnectedSocket() client: Socket) { this.logger.log(`Client connected: ${client.id}`); await this.chatService.handleConnection(client); diff --git a/backend/src/chat/chat.service.ts b/backend/src/chat/chat.service.ts index 412acdd8..82f9bb91 100644 --- a/backend/src/chat/chat.service.ts +++ b/backend/src/chat/chat.service.ts @@ -72,7 +72,6 @@ export class ChatService { await this.addUserToRoom(event.roomId, event.userId); } - @OnEvent('room.leave', { async: true }) async removeUserFromRoom(event: RoomLeftEvent) { const client = this.clients.get(event.userId); if (client) { diff --git a/backend/test/chat-gateway.e2e-spec.ts b/backend/test/chat-gateway.e2e-spec.ts index 637f6eb5..7872cf5a 100644 --- a/backend/test/chat-gateway.e2e-spec.ts +++ b/backend/test/chat-gateway.e2e-spec.ts @@ -487,12 +487,59 @@ describe('ChatGateway and ChatController (e2e)', () => { .expect(200); }); + let ctx5, ctx6, ctx7, ctx8, ctx9: Promise<void>; + it('setup promises to recv left-room event with user id', async () => { + ctx5 = new Promise<void>((resolve) => { + ws1.on('left-room', (data) => { + expect(data).toEqual(kickedUser1.id); + ws1.off('left-room'); + resolve(); + }); + }); + ctx6 = new Promise<void>((resolve) => { + ws2.on('left-room', (data) => { + expect(data).toEqual(kickedUser1.id); + ws2.off('left-room'); + resolve(); + }); + }); + ctx7 = new Promise<void>((resolve) => { + ws3.on('left-room', (data) => { + expect(data).toEqual(kickedUser1.id); + ws3.off('left-room'); + resolve(); + }); + }); + ctx8 = new Promise<void>((resolve) => { + ws4.on('left-room', (data) => { + expect(data).toEqual(kickedUser1.id); + ws4.off('left-room'); + resolve(); + }); + }); + ctx9 = new Promise<void>((resolve) => { + ws6.on('left-room', (data) => { + expect(data).toEqual(kickedUser1.id); + ws6.off('left-room'); + resolve(); + }); + }); + }); + it('user1 kicks kickedUser1', async () => { await app .kickFromRoom(room.id, kickedUser1.id, user1.accessToken) .expect(204); }); + it('all users (except kickedUser1) should receive left-room event with kickedUser1 id', async () => { + await ctx5; + await ctx6; + await ctx7; + await ctx8; + await ctx9; + }); + it('kickedUser1 sends message', () => { const helloMessage = { userId: kickedUser1.id, diff --git a/frontend/app/room/[id]/sidebar-item.tsx b/frontend/app/room/[id]/sidebar-item.tsx index 89fb8d09..13775fe8 100644 --- a/frontend/app/room/[id]/sidebar-item.tsx +++ b/frontend/app/room/[id]/sidebar-item.tsx @@ -20,7 +20,8 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { chatSocket as socket } from "@/socket"; function truncateString(str: string | undefined, num: number): string { if (!str) { @@ -50,14 +51,31 @@ export default function SidebarItem({ me: UserOnRoomEntity; blockingUsers: PublicUserEntity[]; }) { + const router = useRouter(); const [isBlocked, setIsBlocked] = useState( blockingUsers.some((u: PublicUserEntity) => u.id === user.userId), ); + const [isKicked, setIsKicked] = useState(false); + useEffect(() => { + const handleLeftEvent = (userId: string) => { + if (Number(userId) === me.userId) { + router.push("/"); + } + if (Number(userId) === user.userId) { + setIsKicked(true); + } + }; + socket.on("left-room", handleLeftEvent); + + return () => { + socket.off("left-room", handleLeftEvent); + }; + }, [user.userId, me.userId, router]); + const isUserAdmin = user.role === "ADMINISTRATOR"; const isUserOwner = user.role === "OWNER"; const isMeAdminOrOwner = me.role === "ADMINISTRATOR" || me.role === "OWNER"; - const router = useRouter(); const openProfile = () => { if (user.userId === me.userId) { router.push("/settings"); @@ -84,40 +102,42 @@ export default function SidebarItem({ : () => updateRoomUser("ADMINISTRATOR", room.id, user.userId); return ( <> - <ContextMenu> - <ContextMenuTrigger className="flex gap-2 items-center group hover:opacity-60"> - <Avatar avatarURL={user.user.avatarURL} /> - <span className="text-muted-foreground text-sm whitespace-nowrap group-hover:text-primary"> - {truncateString(user.user.name, 15)} - {room.accessLevel !== "DIRECT" && isUserOwner && " 👑"} - {room.accessLevel !== "DIRECT" && isUserAdmin && " 🛡"} - </span> - </ContextMenuTrigger> - <ContextMenuContent className="w-56"> - <ContextMenuItem onSelect={openProfile}>Go profile</ContextMenuItem> - {user.userId !== me.userId && ( - <> - <ContextMenuSeparator /> - <ContextMenuItem disabled={isBlocked} onSelect={block}> - Block - </ContextMenuItem> - <ContextMenuItem disabled={!isBlocked} onSelect={unblock}> - Unblock - </ContextMenuItem> - {isMeAdminOrOwner && !isUserOwner && ( - <> - <ContextMenuItem disabled={isUserOwner} onSelect={kick}> - Kick - </ContextMenuItem> - <ContextMenuItem onSelect={updateUserRole}> - {isUserAdmin ? "Remove admin role" : "Promote to Admin"} - </ContextMenuItem> - </> - )} - </> - )} - </ContextMenuContent> - </ContextMenu> + {!isKicked && ( + <ContextMenu> + <ContextMenuTrigger className="flex gap-2 items-center group hover:opacity-60"> + <Avatar avatarURL={user.user.avatarURL} /> + <span className="text-muted-foreground text-sm whitespace-nowrap group-hover:text-primary"> + {truncateString(user.user.name, 15)} + {room.accessLevel !== "DIRECT" && isUserOwner && " 👑"} + {room.accessLevel !== "DIRECT" && isUserAdmin && " 🛡"} + </span> + </ContextMenuTrigger> + <ContextMenuContent className="w-56"> + <ContextMenuItem onSelect={openProfile}>Go profile</ContextMenuItem> + {user.userId !== me.userId && ( + <> + <ContextMenuSeparator /> + <ContextMenuItem disabled={isBlocked} onSelect={block}> + Block + </ContextMenuItem> + <ContextMenuItem disabled={!isBlocked} onSelect={unblock}> + Unblock + </ContextMenuItem> + {isMeAdminOrOwner && !isUserOwner && ( + <> + <ContextMenuItem disabled={isUserOwner} onSelect={kick}> + Kick + </ContextMenuItem> + <ContextMenuItem onSelect={updateUserRole}> + {isUserAdmin ? "Remove admin role" : "Promote to Admin"} + </ContextMenuItem> + </> + )} + </> + )} + </ContextMenuContent> + </ContextMenu> + )} </> ); }