diff --git a/.env.template b/.env.template index 70c19aef..8a33c045 100644 --- a/.env.template +++ b/.env.template @@ -10,8 +10,5 @@ JWT_PUBLIC_KEY= JWT_PRIVATE_KEY= FRONTEND_JWT_SECRET= TWO_FACTOR_AUTHENTICATION_APP_NAME= -OAUTH_GOOGLE_CLIENT_ID= -OAUTH_GOOGLE_CLIENT_SECRET= OAUTH_42_CLIENT_ID= -OAUTH_42_CLIENT_SECRET= -OAUTH_REDIRECT_URI= \ No newline at end of file +OAUTH_42_CLIENT_SECRET= \ No newline at end of file diff --git a/backend/src/chat/chat.gateway.ts b/backend/src/chat/chat.gateway.ts index 76fccf93..b250b05a 100644 --- a/backend/src/chat/chat.gateway.ts +++ b/backend/src/chat/chat.gateway.ts @@ -93,7 +93,14 @@ export class ChatGateway { @SubscribeMessage('invite-cancel-pong') handleInviteCancelPong(@ConnectedSocket() client: Socket) { const inviteUser = this.chatService.getUser(client); + const invitee = this.chatService.getInvite(inviteUser.id); + if (!invitee) { + this.server.to(client.id).emit('error-pong', 'No pending invite found.'); + return; + } + const inviteeWsId = this.chatService.getWsFromUserId(invitee)?.id; this.chatService.removeInvite(inviteUser.id); + this.server.to(inviteeWsId).emit('invite-cancel-pong', inviteUser); } @SubscribeMessage('approve-pong') diff --git a/backend/src/chat/chat.service.ts b/backend/src/chat/chat.service.ts index 2ccfb8af..56b8dcd2 100644 --- a/backend/src/chat/chat.service.ts +++ b/backend/src/chat/chat.service.ts @@ -12,6 +12,7 @@ import { UnblockEvent } from 'src/common/events/unblock.event'; import { PrismaService } from 'src/prisma/prisma.service'; import { UserService } from 'src/user/user.service'; import { CreateMessageDto } from './dto/create-message.dto'; +import { PublicUserEntity } from './entities/message.entity'; @Injectable() @WebSocketGateway() @@ -24,7 +25,7 @@ export class ChatService { // Map private clients = new Map(); - private users = new Map(); + private users = new Map(); // key: inviter, value: invitee private invite = new Map(); @@ -46,7 +47,7 @@ export class ChatService { addClient(user: User, client: Socket) { this.clients.set(user.id, client); - this.users.set(client.id, user); + this.users.set(client.id, new PublicUserEntity(user)); } removeClient(client: Socket) { diff --git a/backend/src/chat/entities/message.entity.ts b/backend/src/chat/entities/message.entity.ts index 239107a8..a16010c8 100644 --- a/backend/src/chat/entities/message.entity.ts +++ b/backend/src/chat/entities/message.entity.ts @@ -1,7 +1,5 @@ -import { User } from '@prisma/client'; - -class PrivateUserEntity { - constructor(partial: Partial) { +export class PublicUserEntity { + constructor(partial: Partial) { this.id = partial.id; this.name = partial.name; this.avatarURL = partial.avatarURL; @@ -12,12 +10,12 @@ class PrivateUserEntity { } export class MessageEntity { - constructor(partial: Partial, user: User) { + constructor(partial: Partial, user: PublicUserEntity) { this.content = partial.content; this.roomId = partial.roomId; - this.user = new PrivateUserEntity(user); + this.user = user; } content: string; roomId: number; - user: PrivateUserEntity; + user: PublicUserEntity; } diff --git a/backend/test/chat-gateway.e2e-spec.ts b/backend/test/chat-gateway.e2e-spec.ts index 8dc26087..a8ddc4f6 100644 --- a/backend/test/chat-gateway.e2e-spec.ts +++ b/backend/test/chat-gateway.e2e-spec.ts @@ -1332,7 +1332,7 @@ describe('ChatGateway and ChatController (e2e)', () => { }); }); }); - it('invite user should receive an error', () => ctxToDeny); + it('inviter should receive an deny message', () => ctxToDeny); it('unrelated user should not receive any messages', () => new Promise((resolve) => setTimeout(() => { @@ -1371,5 +1371,58 @@ describe('ChatGateway and ChatController (e2e)', () => { )); }); }); + describe('invite-cancel', () => { + describe('success case', () => { + const mockCallback1 = jest.fn(); + let ctxToCancel: Promise; + + beforeAll(() => { + const inviter = userAndSockets[0]; + const invitee = userAndSockets[1]; + const notInvited1 = userAndSockets[2]; + + notInvited1.ws.on('invite-pong', mockCallback1); + notInvited1.ws.on('invite-cancel-pong', mockCallback1); + + const promiseToInvite = new Promise((resolve) => + invitee.ws.on('invite-pong', (data) => resolve(data)), + ); + inviter.ws.emit('invite-pong', { + userId: invitee.user.id, + }); + ctxToCancel = new Promise((resolve) => + invitee.ws.on('invite-cancel-pong', (data) => resolve(data)), + ); + return promiseToInvite.then(() => { + inviter.ws.emit('invite-cancel-pong'); + }); + }); + it('invitee should receive an invite-cancel message', () => + ctxToCancel.then((data) => { + expect(data).toHaveProperty('id'); + expect(data).toHaveProperty('avatarURL'); + expect(data).toHaveProperty('name'); + })); + it('unrelated user should not receive any messages', () => + new Promise((resolve) => + setTimeout(() => { + expect(mockCallback1).not.toHaveBeenCalled(); + resolve(); + }, waitTime), + )); + }); + describe('failure case', () => { + let errorCtx: Promise; + beforeAll(() => { + const canceler = userAndSockets[0]; + errorCtx = new Promise((resolve) => + canceler.ws.on('error-pong', (data) => resolve(data)), + ); + canceler.ws.emit('invite-cancel-pong'); + }); + it('should receive an error when canceling without an existing invite', () => + errorCtx); + }); + }); }); }); diff --git a/compose.yml b/compose.yml index 57206b82..60fe18ba 100644 --- a/compose.yml +++ b/compose.yml @@ -20,7 +20,6 @@ services: JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY} JWT_SECRET: ${FRONTEND_JWT_SECRET} OAUTH_42_CLIENT_ID: ${OAUTH_42_CLIENT_ID} - OAUTH_REDIRECT_URI: ${OAUTH_REDIRECT_URI} depends_on: backend: condition: service_healthy diff --git a/frontend/app/(guest-only)/login/page.tsx b/frontend/app/(guest-only)/login/page.tsx index 81d04672..3d9c4ad9 100644 --- a/frontend/app/(guest-only)/login/page.tsx +++ b/frontend/app/(guest-only)/login/page.tsx @@ -4,7 +4,9 @@ export default function LoginPage() { return ( <> - login with 42 + + login with 42 + ); } diff --git a/frontend/app/(guest-only)/signup/oauth/page.tsx b/frontend/app/(guest-only)/signup/oauth/page.tsx deleted file mode 100644 index a1be518d..00000000 --- a/frontend/app/(guest-only)/signup/oauth/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { redirect } from "next/navigation"; - -const signupWith42 = () => { - const response_type = "code"; - const client_id = process.env.OAUTH_42_CLIENT_ID; - const redirect_uri = process.env.OAUTH_REDIRECT_URI; - const scope = "public"; - const state = "42"; - - const url = - "https://api.intra.42.fr/oauth/authorize?" + - `&client_id=${client_id}` + - `&redirect_uri=${redirect_uri}` + - `&response_type=${response_type}` + - `&scope=${scope}` + - `&state=${state}`; - redirect(url); -}; - -export default signupWith42; diff --git a/frontend/app/(guest-only)/signup/oauth/redirect/page.tsx b/frontend/app/(guest-only)/signup/oauth/redirect/page.tsx deleted file mode 100644 index 755b1069..00000000 --- a/frontend/app/(guest-only)/signup/oauth/redirect/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createUserWithOauth } from "@/app/lib/actions"; - -const Callback = async ({ - searchParams, -}: { - searchParams?: { [key: string]: string | string[] | undefined }; -}) => { - if (searchParams === undefined) { - return

hoge

; - } - console.log(searchParams); - if (searchParams["code"] === undefined) { - return

hoge

; - } - await createUserWithOauth(searchParams["code"], "42"); - return

hoge

; -}; - -export default Callback; diff --git a/frontend/app/(guest-only)/signup/page.tsx b/frontend/app/(guest-only)/signup/page.tsx index 01bea0f5..3d6e7a7a 100644 --- a/frontend/app/(guest-only)/signup/page.tsx +++ b/frontend/app/(guest-only)/signup/page.tsx @@ -5,7 +5,7 @@ export default function SignUp() { return ( <> - + sign up with 42 diff --git a/frontend/app/lib/client-socket-provider.tsx b/frontend/app/lib/client-socket-provider.tsx index c6e169c9..0c2de3de 100644 --- a/frontend/app/lib/client-socket-provider.tsx +++ b/frontend/app/lib/client-socket-provider.tsx @@ -5,11 +5,55 @@ import { chatSocket } from "@/socket"; import Link from "next/link"; import { useEffect } from "react"; import { useAuthContext } from "./client-auth"; -import { MessageEvent } from "./dtos"; +import { + DenyEvent, + InviteEvent, + MatchEvent, + MessageEvent, + PublicUserEntity, +} from "./dtos"; +import { chatSocket as socket } from "@/socket"; +import { useRouter } from "next/navigation"; export default function SocketProvider() { const { toast } = useToast(); const { currentUser } = useAuthContext(); + const router = useRouter(); + + const MatchPong = (data: MatchEvent) => { + router.push(`/pong/${data.roomId}?mode=player`); + }; + + const showInvitePongToast = (message: InviteEvent) => { + toast({ + title: `user id: ${message.userId}`, + description: ` invited you to play pong!`, + action: ( + + <> + + + + + ), + }); + }; const showMessageToast = (message: MessageEvent) => { // TODO: If sender is me, don't show toast @@ -31,11 +75,40 @@ export default function SocketProvider() { }); }; + const showDenyPongToast = (data: DenyEvent) => { + toast({ + title: `Your invite was denied`, + }); + }; + + const showErrorPongToast = (data: any) => { + toast({ + title: `Error`, + description: ` ${data}`, + }); + }; + + const showInviteCancelPongToast = (data: PublicUserEntity) => { + toast({ + title: `Invite canceled by ${data.name}`, + }); + }; + useEffect(() => { chatSocket.connect(); const handler = (event: string, data: any) => { if (event === "message") { showMessageToast(data); + } else if (event === "invite-pong") { + showInvitePongToast(data); + } else if (event === "invite-cancel-pong") { + showInviteCancelPongToast(data); + } else if (event === "match-pong") { + MatchPong(data); + } else if (event === "deny-pong") { + showDenyPongToast(data); + } else if (event === "error-pong") { + showErrorPongToast(data); } else { showNotificationToast(data); } diff --git a/frontend/app/lib/dtos.ts b/frontend/app/lib/dtos.ts index b4e70954..09b7e613 100644 --- a/frontend/app/lib/dtos.ts +++ b/frontend/app/lib/dtos.ts @@ -62,4 +62,14 @@ export type MessageEvent = { roomId: number; }; +export type InviteEvent = { + userId: number; +}; + +export type MatchEvent = { + roomId: string; +}; + +export type DenyEvent = {}; + export type RoomEntity = { id: number; name: string; accessLevel: AccessLevel }; diff --git a/frontend/app/room/[id]/sidebar-item.tsx b/frontend/app/room/[id]/sidebar-item.tsx index 5c310596..9f4c93e7 100644 --- a/frontend/app/room/[id]/sidebar-item.tsx +++ b/frontend/app/room/[id]/sidebar-item.tsx @@ -71,6 +71,7 @@ export default function SidebarItem({ const [isMuted, setIsMuted] = useState( mutedUsers?.some((u: PublicUserEntity) => u.id === user.userId), ); + const [isInviting, setIsInviting] = useState(false); useEffect(() => { const handleLeftEvent = (data: LeaveEvent) => { if (Number(data.userId) === me.userId) { @@ -111,6 +112,14 @@ export default function SidebarItem({ setIsBlocked(false); } }; + const invite = () => { + socket.emit("invite-pong", { userId: user.userId }); + setIsInviting(true); + }; + const cancelInvite = () => { + socket.emit("invite-cancel-pong", { userId: user.userId }); + setIsInviting(false); + }; const mute = async (duration?: number) => { const res = await muteUser(room.id, user.userId, duration); if (res === "Success") { @@ -150,6 +159,17 @@ export default function SidebarItem({ Unblock + {!isInviting && ( + + {/* TODO: disabled when inviting */} + Invite + + )} + {isInviting && ( + + Cancel invite + + )} {isMeAdminOrOwner && !isUserOwner && ( <> {!isMuted && (