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>
+      )}
     </>
   );
 }