diff --git a/front/src/components/Navbar.tsx b/front/src/components/Navbar.tsx
index 153e80b..2b04056 100644
--- a/front/src/components/Navbar.tsx
+++ b/front/src/components/Navbar.tsx
@@ -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' },
];
diff --git a/front/src/components/chat/ChatList.tsx b/front/src/components/chat/ChatList.tsx
index 5dd4bc5..9a64a59 100644
--- a/front/src/components/chat/ChatList.tsx
+++ b/front/src/components/chat/ChatList.tsx
@@ -41,7 +41,7 @@ interface ChatListProps {
const ChatList = ({ joinedChannels, currentChannel, setCurrentChannel }: ChatListProps) => {
return (
-
+
{joinedChannels.map((chat) => (
>;
+ socket: Socket;
+}
+
+const CreateDM = ({ setShowModal, socket }: CreateDMProps) => {
+ const [channelType, setChannelType] = useState('Public');
+ const passwordRef = useRef(null);
+ const nameRef = useRef(null);
+
+ const CreateDM = (e: FormEvent) => {
+ 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 (
+
+ );
+};
+
+export default CreateDM;
diff --git a/front/src/components/friends/DM.tsx b/front/src/components/friends/DM.tsx
new file mode 100644
index 0000000..130cc13
--- /dev/null
+++ b/front/src/components/friends/DM.tsx
@@ -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(false);
+ const [showJoinModal, setShowJoinModal] = useState(false);
+ const [socket, setSocket] = useState();
+ const localToken = localStorage.getItem('token');
+ const token: string = localToken ? localToken : '';
+ const [loading, setLoading] = useState(true);
+ const [joinedChannels, setJoinedChannels] = useState([]);
+ const [currentChannel, setCurrentChannel] = useState(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 loading
;
+ if (!socket) return socket not initialized
;
+
+ return (
+
+ {showCreateModal && (
+
+
+
+ )}
+ {showJoinModal && (
+
+
+
+ )}
+
+
+
DMMessages
+
+
+
+
+
+
+
+ {currentChannel &&
}
+
+ );
+};
+
+export default DM;
diff --git a/front/src/components/friends/DMConversation.tsx b/front/src/components/friends/DMConversation.tsx
new file mode 100644
index 0000000..15c5285
--- /dev/null
+++ b/front/src/components/friends/DMConversation.tsx
@@ -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(false);
+ const [messages, setMessages] = useState([]);
+ const [message, setMessage] = useState('');
+ const bottomEl = useRef(null);
+ const {
+ data: infos,
+ isError,
+ isLoading,
+ } = useApi().get('get user infos', '/user/me') as UseQueryResult;
+
+ 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) => {
+ e.preventDefault();
+ if (!message) return;
+ socket.emit('message', { channel: channel.name, content: message });
+ setMessage('');
+ };
+
+ if (isLoading) return loading
;
+ if (isError) return error
;
+ if (!infos) return error
;
+
+ return (
+
+ {showModal && (
+
+
+
+ )}
+
+
{channel.name}
+
+
+
+
+
+ {messages.map((m, idx) => {
+ // TODO replace idx by DMMessage id
+ return (
+
+ );
+ })}
+
+
+
+
+ );
+};
+
+export default DMDMConversation;
diff --git a/front/src/components/friends/DMInfos.tsx b/front/src/components/friends/DMInfos.tsx
new file mode 100644
index 0000000..c3e5dc8
--- /dev/null
+++ b/front/src/components/friends/DMInfos.tsx
@@ -0,0 +1,152 @@
+import React, { useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { Socket } from 'socket.io-client';
+
+import ban_icon from '@/assets/chat/ban.svg';
+import game_icon from '@/assets/chat/boxing-glove.svg';
+import promote_icon from '@/assets/chat/crown.svg';
+import demote_icon from '@/assets/chat/demote.svg';
+import kick_icon from '@/assets/chat/kick.svg';
+
+import { UserType } from './DMConversation';
+
+interface DMInfosProps {
+ setShowModal: (show: boolean) => void;
+ socket: Socket;
+ channelName: string;
+ currentUserLogin: string;
+}
+
+interface UserListResponse {
+ channel: string;
+ users: UserType[];
+}
+
+// TODO: leaver button ??
+const DMInfos = ({ setShowModal, socket, channelName, currentUserLogin }: DMInfosProps) => {
+ const [users, setUsers] = useState([]);
+ const [isAdmin, setIsAdmin] = useState(false);
+
+ useEffect(() => {
+ socket.emit('userList', { channel: channelName }, (res: UserListResponse) => {
+ setUsers(res.users.filter((user) => user.login !== currentUserLogin));
+ const user = res.users.find((user) => user.login === currentUserLogin) || null;
+ if (user && (user.role === 'ADMIN' || user.role === 'OWNER')) setIsAdmin(true);
+ });
+ }, []);
+
+ const promoteUser = (user: UserType) => {
+ socket.emit('promote', { channel: channelName, login: user.login });
+ setUsers(
+ users.map((u) => {
+ if (u.id === user.id) {
+ return { ...u, role: 'ADMIN' };
+ }
+ return u;
+ }),
+ );
+ };
+
+ const demoteUser = (user: UserType) => {
+ socket.emit('demote', { channel: channelName, login: user.login });
+ setUsers(
+ users.map((u) => {
+ if (u.id === user.id) {
+ return { ...u, role: 'USER' };
+ }
+ return u;
+ }),
+ );
+ };
+
+ const kickUser = (user: UserType) => {
+ socket.emit('kick', { channel: channelName, login: user.login });
+ setUsers(users.filter((u) => u.id !== user.id));
+ };
+
+ const banUser = (user: UserType) => {
+ socket.emit('ban', { channel: channelName, login: user.login });
+ setUsers(users.filter((u) => u.id !== user.id));
+ };
+
+ const startGame = () => {
+ const code = (Math.random() + 1).toString(36).substring(7);
+ const DMMessage = `Come join me in a Pong game! ${window.location.origin}/game?code=${code}`;
+ socket.emit('DMMessage', { channel: channelName, content: DMMessage });
+ };
+
+ // Contains the list of members in the channel, whith a possibility to kick them, to promote them as admin, and to start a game with them
+ return (
+
+
+
Members
+
+
+
+ {users.map((user) => {
+ return (
+
+
+
+
+
{user.login}
+
+
+
+
+ {user.role === 'ADMIN' || user.role === 'OWNER' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default DMInfos;
diff --git a/front/src/components/friends/DMList.tsx b/front/src/components/friends/DMList.tsx
new file mode 100644
index 0000000..687e4f8
--- /dev/null
+++ b/front/src/components/friends/DMList.tsx
@@ -0,0 +1,57 @@
+// import logo from '@/assets/logo.svg';
+
+import { ChannelType } from './DM';
+
+interface DMListElemProps {
+ DMInfos: ChannelType;
+ currentChannel: ChannelType | null;
+ setCurrentChannel: (channel: ChannelType) => void;
+}
+
+const DMListElem = ({ DMInfos, setCurrentChannel, currentChannel }: DMListElemProps) => {
+ const handleClick = (channel: ChannelType) => {
+ if (currentChannel === null || channel.id !== currentChannel.id) setCurrentChannel(channel);
+ };
+
+ return (
+
+ );
+};
+
+interface DMListProps {
+ joinedChannels: ChannelType[];
+ currentChannel: ChannelType | null;
+ setCurrentChannel: (channel: ChannelType) => void;
+}
+
+const DMList = ({ joinedChannels, currentChannel, setCurrentChannel }: DMListProps) => {
+ return (
+
+ {joinedChannels.map((chat) => (
+
+ ))}
+
+ );
+};
+
+export default DMList;
diff --git a/front/src/components/friends/DMMessage.tsx b/front/src/components/friends/DMMessage.tsx
new file mode 100644
index 0000000..b0cf33e
--- /dev/null
+++ b/front/src/components/friends/DMMessage.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import Linkify from 'react-linkify';
+import { Link } from 'react-router-dom';
+
+import { UserType } from './DMConversation';
+
+interface DMMessageProps {
+ sender: UserType;
+ text: string;
+ send_by_user: boolean;
+}
+
+const DMMessage = ({ text, sender, send_by_user }: DMMessageProps) => {
+ return (
+
+
+
+
{sender.login}
+
+
(
+
+ {decoratedText}
+
+ )}
+ >
+ {text}
+
+
+ );
+};
+
+export default DMMessage;
diff --git a/front/src/components/friends/DMModal.tsx b/front/src/components/friends/DMModal.tsx
new file mode 100644
index 0000000..2a875fd
--- /dev/null
+++ b/front/src/components/friends/DMModal.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+
+interface ModalProps {
+ children: React.ReactNode;
+}
+
+const Modal = ({ children }: ModalProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default Modal;
diff --git a/front/src/components/friends/JoinDM.tsx b/front/src/components/friends/JoinDM.tsx
new file mode 100644
index 0000000..e403a3c
--- /dev/null
+++ b/front/src/components/friends/JoinDM.tsx
@@ -0,0 +1,117 @@
+import React, { useEffect, useState } from 'react';
+import { Socket } from 'socket.io-client';
+
+import lock from '@/assets/chat/lock.svg';
+
+import { ChannelType } from './DM';
+
+interface JoinDMProps {
+ setShowModal: React.Dispatch>;
+ socket: Socket;
+ joinedChannels: ChannelType[];
+}
+
+const JoinDM = ({ setShowModal, socket, joinedChannels }: JoinDMProps) => {
+ const [searchChannel, setSearchChannel] = useState('');
+ const [isPasswordNeeded, setIsPasswordNeeded] = useState(false);
+ const [channelSelected, setChannelSelected] = useState(null);
+ const [channels, setChannels] = useState([]);
+ const [channelName, setChannelName] = useState('');
+ const [password, setPassword] = useState('');
+
+ useEffect(() => {
+ socket.emit('channelList', (data: ChannelType[]) => {
+ setChannels(data);
+ });
+ }, []);
+
+ const selectChannel = (e: React.MouseEvent, channelType: ChannelType) => {
+ if (channelType.type === 'PROTECTED') {
+ setIsPasswordNeeded(true);
+ } else {
+ setIsPasswordNeeded(false);
+ }
+
+ if (e.currentTarget !== channelSelected) {
+ setChannelName(e.currentTarget.firstChild?.textContent || '');
+ if (channelSelected) {
+ channelSelected.style.backgroundColor = '#FFFFFF';
+ channelSelected.style.color = '#000000';
+ }
+ e.currentTarget.style.backgroundColor = '#37626D';
+ e.currentTarget.style.color = '#FFFFFF';
+ setChannelSelected(e.currentTarget);
+ }
+ };
+
+ const handleJoinDM = () => {
+ socket.emit('join', { name: channelName, password: password });
+ // TODO send notification if error
+ setShowModal(false);
+ };
+
+ return (
+
+ );
+};
+
+export default JoinDM;
diff --git a/front/src/main.tsx b/front/src/main.tsx
index a9dae46..99eba90 100644
--- a/front/src/main.tsx
+++ b/front/src/main.tsx
@@ -7,7 +7,6 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Root from '@/components/Root';
import Error from '@/pages/Error';
-import Friends from '@/pages/Friends';
import Game from '@/pages/Game';
import Home from '@/pages/Home';
import Login from '@/pages/Login';
@@ -15,6 +14,8 @@ import Profile from '@/pages/Profile';
import { privateGuard } from '@/utils/privateGuard';
import Main from './components/Main';
+import ChatPage from './pages/ChatPage';
+import FriendPage from './pages/FriendPage';
import OauthCallback from './pages/OauthCallback';
import TwoFaActivation from './pages/TwoFaActivation';
import TwoFaLogin from './pages/TwoFaLogin';
@@ -45,7 +46,12 @@ const router = createBrowserRouter([
},
{
path: '/friends',
- element: ,
+ element: ,
+ loader: privateGuard,
+ },
+ {
+ path: '/chat',
+ element: ,
loader: privateGuard,
},
{
diff --git a/front/src/pages/Friends.tsx b/front/src/pages/ChatPage.tsx
similarity index 86%
rename from front/src/pages/Friends.tsx
rename to front/src/pages/ChatPage.tsx
index aaf5a95..b42d48e 100644
--- a/front/src/pages/Friends.tsx
+++ b/front/src/pages/ChatPage.tsx
@@ -2,7 +2,7 @@ import { Button } from '@/components/Button';
import Chat from '@/components/chat/Chat';
import { FriendList } from '@/components/FriendList';
-const Friends = () => {
+const ChatPage = () => {
return (
@@ -11,4 +11,4 @@ const Friends = () => {
);
};
-export default Friends;
+export default ChatPage;
diff --git a/front/src/pages/FriendPage.tsx b/front/src/pages/FriendPage.tsx
new file mode 100644
index 0000000..fd8c80f
--- /dev/null
+++ b/front/src/pages/FriendPage.tsx
@@ -0,0 +1,11 @@
+import DM from '@/components/friends/DM';
+
+const FriendPage = () => {
+ return (
+
+
+
+ );
+};
+
+export default FriendPage;