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 ( +
+

Create a channel

+
+ + + {channelType === 'protected' && ( + + )} + +
+ + +
+
+
+ ); +}; + +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 ( + + ); + })} +
+
+
+
sendDMMessage(e)} + > + setMessage(e.target.value)} + /> + +
+
+
+ ); +}; + +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 +

{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 ( +
+
+ user + {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 ( +
+

Join a channel

+
+
+ setSearchChannel(e.target.value)} + /> + +
+
+ {channels + .filter((channel) => channel.name.toLowerCase().includes(searchChannel.toLowerCase())) + .map((channel) => { + const joined = joinedChannels.some( + (joinedChannel) => joinedChannel.id === channel.id, + ); + return ( + + ); + })} +
+
+ setPassword(e.target.value)} + /> + +
+
+
+ ); +}; + +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;