Skip to content

Commit

Permalink
Feat/frontend/chat/invite pong (#227)
Browse files Browse the repository at this point in the history
* [backend] Update invite user test to use correct error
message

* [frontend] Add Pong game invitation functionality

* [backend] invite-cancel-pong event

* [backend] rename PrivateUserEntity -> PublicUserEntity

* [backend] [need review] ws に紐づけるuser の情報を 公開しても良い情報だけにした。

* [backend] add test to invite-cancel-pong

* [backend] to avoid duplicate class name. fix test

* [backend] delete don't use file and environment variable

* [frontend] fix ip address hard coding with oauth
  • Loading branch information
kotto5 authored Jan 23, 2024
1 parent 5d8bc31 commit f2aa2fa
Show file tree
Hide file tree
Showing 13 changed files with 178 additions and 57 deletions.
5 changes: 1 addition & 4 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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=
OAUTH_42_CLIENT_SECRET=
7 changes: 7 additions & 0 deletions backend/src/chat/chat.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
5 changes: 3 additions & 2 deletions backend/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -24,7 +25,7 @@ export class ChatService {

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

Expand All @@ -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) {
Expand Down
12 changes: 5 additions & 7 deletions backend/src/chat/entities/message.entity.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { User } from '@prisma/client';

class PrivateUserEntity {
constructor(partial: Partial<PrivateUserEntity>) {
export class PublicUserEntity {
constructor(partial: Partial<PublicUserEntity>) {
this.id = partial.id;
this.name = partial.name;
this.avatarURL = partial.avatarURL;
Expand All @@ -12,12 +10,12 @@ class PrivateUserEntity {
}

export class MessageEntity {
constructor(partial: Partial<MessageEntity>, user: User) {
constructor(partial: Partial<MessageEntity>, 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;
}
55 changes: 54 additions & 1 deletion backend/test/chat-gateway.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) =>
setTimeout(() => {
Expand Down Expand Up @@ -1371,5 +1371,58 @@ describe('ChatGateway and ChatController (e2e)', () => {
));
});
});
describe('invite-cancel', () => {
describe('success case', () => {
const mockCallback1 = jest.fn();
let ctxToCancel: Promise<any>;

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<any>((resolve) =>
invitee.ws.on('invite-pong', (data) => resolve(data)),
);
inviter.ws.emit('invite-pong', {
userId: invitee.user.id,
});
ctxToCancel = new Promise<any>((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<void>((resolve) =>
setTimeout(() => {
expect(mockCallback1).not.toHaveBeenCalled();
resolve();
}, waitTime),
));
});
describe('failure case', () => {
let errorCtx: Promise<any>;
beforeAll(() => {
const canceler = userAndSockets[0];
errorCtx = new Promise<any>((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);
});
});
});
});
1 change: 0 additions & 1 deletion compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/(guest-only)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ export default function LoginPage() {
return (
<>
<LoginForm />
<a href="http://localhost:4242/api/auth/login/oauth2/42">login with 42</a>
<a href={`${process.env.NEXT_PUBLIC_API_URL}/auth/login/oauth2/42`}>
login with 42
</a>
</>
);
}
20 changes: 0 additions & 20 deletions frontend/app/(guest-only)/signup/oauth/page.tsx

This file was deleted.

19 changes: 0 additions & 19 deletions frontend/app/(guest-only)/signup/oauth/redirect/page.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion frontend/app/(guest-only)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default function SignUp() {
return (
<>
<SignUpForm />
<a href="http://localhost:4242/api/auth/signup/oauth2/42">
<a href={`${process.env.NEXT_PUBLIC_API_URL}/auth/signup/oauth2/42`}>
sign up with 42
</a>
</>
Expand Down
75 changes: 74 additions & 1 deletion frontend/app/lib/client-socket-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<ToastAction altText="approve" asChild>
<>
<button
onClick={() => {
socket.emit("approve-pong", {
userId: message.userId,
});
}}
>
approve
</button>
<button
onClick={() => {
socket.emit("deny-pong", {
userId: message.userId,
});
}}
>
Deny
</button>
</>
</ToastAction>
),
});
};

const showMessageToast = (message: MessageEvent) => {
// TODO: If sender is me, don't show toast
Expand All @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions frontend/app/lib/dtos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
20 changes: 20 additions & 0 deletions frontend/app/room/[id]/sidebar-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -150,6 +159,17 @@ export default function SidebarItem({
<ContextMenuItem disabled={!isBlocked} onSelect={unblock}>
Unblock
</ContextMenuItem>
{!isInviting && (
<ContextMenuItem onSelect={invite}>
{/* TODO: disabled when inviting */}
Invite
</ContextMenuItem>
)}
{isInviting && (
<ContextMenuItem onSelect={cancelInvite}>
Cancel invite
</ContextMenuItem>
)}
{isMeAdminOrOwner && !isUserOwner && (
<>
{!isMuted && (
Expand Down

0 comments on commit f2aa2fa

Please sign in to comment.