Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Private message page #85

Merged
merged 16 commits into from
Nov 22, 2023
1 change: 1 addition & 0 deletions front/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Dropdown } from './Dropdown';
const navigation = [
{ name: 'Game', href: '/game' },
{ name: 'Profile', href: '/profile' },
{ name: 'Chat', href: '/chat' },
{ name: 'Friends', href: '/friends' },
{ name: '2FA', href: '/2fa' },
];
Expand Down
2 changes: 1 addition & 1 deletion front/src/components/chat/ChatList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ interface ChatListProps {

const ChatList = ({ joinedChannels, currentChannel, setCurrentChannel }: ChatListProps) => {
return (
<div className="h-full w-full">
<div className="h-full w-full overflow-y-auto" style={{ maxHeight: '600px' }}>
{joinedChannels.map((chat) => (
<ChatListElem
setCurrentChannel={setCurrentChannel}
Expand Down
74 changes: 74 additions & 0 deletions front/src/components/friends/CreateDM.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, { FormEvent, useRef, useState } from 'react';
import { Socket } from 'socket.io-client';

import { Channel } from './DM';

interface CreateDMProps {
setShowModal: React.Dispatch<React.SetStateAction<boolean>>;
socket: Socket;
}

const CreateDM = ({ setShowModal, socket }: CreateDMProps) => {
const [channelType, setChannelType] = useState<string>('Public');
const passwordRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);

const CreateDM = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// send notification if success or error
const channel: Channel = {
name: nameRef.current?.value || '',
type: channelType.toUpperCase(),
};
if (channelType === 'protected') {
channel.password = passwordRef.current?.value;
}
socket.emit('create', channel);
setShowModal(false);
};
return (
<div className="flex flex-col items-center justify-center rounded-lg bg-white-2 p-6 shadow-xl">
<h2 className="mb-4 text-2xl">Create a channel</h2>
<form className="flex flex-col gap-2" onSubmit={CreateDM}>
<input
pattern="[a-z0-9]+"
title="Only lowercase letters and numbers are allowed"
className="rounded-lg border-2 border-white-3 p-2"
type="text"
placeholder="Channel name"
ref={nameRef}
/>
<select
className="rounded-lg border-2 border-white-3 p-2"
onChange={(e) => setChannelType(e.target.value)}
>
<option value="public">Public</option>
<option value="protected">Protected</option>
<option value="private">Private</option>
</select>
{channelType === 'protected' && (
<input
className="rounded-lg border-2 border-white-3 p-2"
type="password"
placeholder="Password"
ref={passwordRef}
/>
)}

<div className="flex justify-between">
<button
onClick={() => setShowModal(false)}
className="rounded-lg border-2 border-white-3 p-2 hover:bg-red hover:text-white-1"
>
Cancel
</button>
<button className="rounded-lg border-2 border-white-3 p-2 hover:bg-darkBlue-2 hover:text-white-1">
Create
</button>
</div>
</form>
</div>
);
};

export default CreateDM;
118 changes: 118 additions & 0 deletions front/src/components/friends/DM.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';

import chat_plus from '@/assets/chat/chat_plus.svg';
import chat_join from '@/assets/chat/join-channel.svg';

import CreateDM from './CreateDM';
import DMConversation from './DMConversation';
import DMList from './DMList';
import DMModal from './DMModal';
import JoinDM from './JoinDM';

export interface Channel {
name: string;
type: string;
password?: string;
}

export interface ChannelType {
createdAt: string;
id: number;
name: string;
type: 'PUBLIC' | 'PROTECTED' | 'PRIVATE';
}

const DM = () => {
const [showCreateModal, setShowCreateModal] = useState<boolean>(false);
const [showJoinModal, setShowJoinModal] = useState<boolean>(false);
const [socket, setSocket] = useState<Socket>();
const localToken = localStorage.getItem('token');
const token: string = localToken ? localToken : '';
const [loading, setLoading] = useState<boolean>(true);
const [joinedChannels, setJoinedChannels] = useState<ChannelType[]>([]);
const [currentChannel, setCurrentChannel] = useState<ChannelType | null>(null);

useEffect(() => {
setLoading(true);
const tmpSocket = io('/chat', { extraHeaders: { token: token } });
tmpSocket.on('connect', () => {
setSocket(tmpSocket);
setLoading(false);

tmpSocket.emit('joinedChannels', (data: ChannelType[]) => {
setJoinedChannels(data);
});

tmpSocket.on('youJoined', (data: ChannelType) => {
setCurrentChannel(data);
setJoinedChannels((prev) => [...prev, data]);
});

tmpSocket.on('youLeft', (data: any) => {
setCurrentChannel(null);
setJoinedChannels(joinedChannels.filter((c) => c.name !== data.channel));
alert(`You left ${data.channel}, ${data.reason}`);
});

tmpSocket.on('exception', (data) => {
if (Array.isArray(data.DMMessage)) {
alert(data.DMMessage.join('\n'));
} else {
alert(data.DMMessage);
}
});
});

return () => {
socket?.disconnect();
};
}, []);
if (loading) return <div>loading</div>;
if (!socket) return <div>socket not initialized</div>;

return (
<div className="flex max-h-full min-h-[75%] w-full bg-white-1 md:w-auto">
{showCreateModal && (
<DMModal>
<CreateDM setShowModal={setShowCreateModal} socket={socket} />
</DMModal>
)}
{showJoinModal && (
<DMModal>
<JoinDM setShowModal={setShowJoinModal} socket={socket} joinedChannels={joinedChannels} />
</DMModal>
)}
<div className="flex w-[35%] flex-col md:w-auto">
<div className="flex justify-between px-1 py-3 md:gap-2 md:p-3">
<h2 className="text-base md:text-xl">DMMessages</h2>
<div className="flex w-full gap-1 md:gap-2">
<button
className="rounded-full p-1 hover:bg-white-3"
title="Join a channel"
onClick={() => setShowJoinModal(true)}
>
<img className="w-5 md:w-6" src={chat_join} alt="join channel" />
</button>
<button
title="Create a channel"
onClick={() => setShowCreateModal(true)}
className="rounded-full p-1 hover:bg-white-3"
>
<img className="w-5 md:w-6" src={chat_plus} alt="create channel" />
</button>
</div>
</div>
<DMList
joinedChannels={joinedChannels}
setCurrentChannel={setCurrentChannel}
currentChannel={currentChannel}
/>
</div>
{currentChannel && <DMConversation socket={socket} channel={currentChannel} />}
</div>
);
};

export default DM;
137 changes: 137 additions & 0 deletions front/src/components/friends/DMConversation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React, { useEffect, useRef, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { Socket } from 'socket.io-client';

import info_icon from '@/assets/chat/info.svg';
import send_icon from '@/assets/chat/send.svg';
import { userDto } from '@/dto/userDto';
import { useApi } from '@/hooks/useApi';

import { ChannelType } from './DM';
import DMInfos from './DMInfos';
import DMMessage from './DMMessage';
import DMModal from './DMModal';

interface DMConversationProps {
channel: ChannelType;
socket: Socket;
}

export interface UserType {
id: number;
login: string;
status: string;
intraImageURL: string;
role: string;
}

export interface DMMessageType {
id: number;
creadtedAt: string;
content: string;
user: UserType;
}

const DMDMConversation = ({ channel, socket }: DMConversationProps) => {
const [showModal, setShowModal] = React.useState<boolean>(false);
const [messages, setMessages] = useState<DMMessageType[]>([]);
const [message, setMessage] = useState<string>('');
const bottomEl = useRef<HTMLDivElement>(null);
const {
data: infos,
isError,
isLoading,
} = useApi().get('get user infos', '/user/me') as UseQueryResult<userDto>;

useEffect(() => {
socket.on('message', (data: DMMessageType) => {
setMessages((messages) => [...messages, data]);
});

socket.emit(
'history',
{ channel: channel.name, offset: 0, limit: 100 },
(res: DMMessageType[]) => {
setMessages(res);
},
);

return () => {
socket.off('message');
};
}, [channel]);

useEffect(() => {
bottomEl?.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);

const sendDMMessage = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!message) return;
socket.emit('message', { channel: channel.name, content: message });
setMessage('');
};

if (isLoading) return <div>loading</div>;
if (isError) return <div>error</div>;
if (!infos) return <div>error</div>;

return (
<div className="flex w-[65%] flex-col border-l border-l-white-3 md:w-[500px]">
{showModal && (
<DMModal>
<DMInfos
setShowModal={setShowModal}
socket={socket}
channelName={channel.name}
currentUserLogin={infos.login}
/>
</DMModal>
)}
<div className="flex justify-between p-3">
<h3 className="text-xl">{channel.name}</h3>
<div className="flex gap-2">
<button className="rounded-full p-1 hover:bg-white-3" onClick={() => setShowModal(true)}>
<img className="w-6" src={info_icon} alt="info" />
</button>
</div>
</div>
<div
className="flex h-full flex-col gap-1 overflow-y-auto p-3"
style={{ maxHeight: '600px' }}
>
{messages.map((m, idx) => {
// TODO replace idx by DMMessage id
return (
<DMMessage
key={idx}
text={m.content}
send_by_user={m.user.login === infos?.login}
sender={m.user}
/>
);
})}
<div ref={bottomEl}></div>
</div>
<div className="border-t border-t-white-3">
<form
className="m-2 flex gap-3 rounded-2xl bg-white-3 p-2"
onSubmit={(e) => sendDMMessage(e)}
>
<input
className="w-full bg-white-3 outline-none"
type="text"
placeholder="Write a new DMMessage"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button>
<img className="w-5" src={send_icon} alt="send" />
</button>
</form>
</div>
</div>
);
};

export default DMDMConversation;
Loading