Skip to content

Commit

Permalink
✨ feat : Add live chat functionality
Browse files Browse the repository at this point in the history
- Chat icon in the video classroom space
- Multi-user enabled confirmed

Related Issue : #167
  • Loading branch information
Lainari committed Jun 19, 2024
1 parent 2e32d6d commit b5a36dd
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 16 deletions.
5 changes: 5 additions & 0 deletions src/api/chatRoom/URLList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const URLList = {
liveURL: 'http://43.203.66.25/api/gin/chat/stream/',
};

export default URLList;
30 changes: 30 additions & 0 deletions src/api/chatRoom/getLiveMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import URLList from './URLList';
import {NativeEventSource, EventSourcePolyfill} from 'event-source-polyfill';
import Cookies from 'js-cookie';

const getLiveMessages = (
scheduleId: number,
onMessage: (message: string) => void
) => {
const token = Cookies.get('access_token');
const url = `${URLList.liveURL}${scheduleId}`;

const EventSource = EventSourcePolyfill || NativeEventSource;
const eventSource = new EventSource(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});

eventSource.onmessage = function (event) {
onMessage(event.data);
};

eventSource.onerror = function (event) {
console.error('EventSource failed:', event);
};

return eventSource;
};

export default getLiveMessages;
8 changes: 8 additions & 0 deletions src/api/chatRoom/getMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import req from '../apiUtils';

const getMessages = async (scheduleId: number) => {
const response = await req(`/chat/messages/${scheduleId}`, 'get', 'gin');
return response;
};

export default getMessages;
13 changes: 13 additions & 0 deletions src/api/chatRoom/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import getLiveMessages from './getLiveMessages';
import getMessages from './getMessages';
import postMessage from './postMessage';
import URLList from './URLList';

const chatRoomAPI = {
getMessages,
postMessage,
getLiveMessages,
URLList,
};

export default chatRoomAPI;
21 changes: 21 additions & 0 deletions src/api/chatRoom/postMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import req from '../apiUtils';

const postMessage = async (
scheduleId: number,
user: string,
message: string
) => {
const formData = new FormData();
formData.append('user', user);
formData.append('message', message);
const response = await req(
`/chat/room/${scheduleId}`,
'post',
'gin',
formData
);

return response.data;
};

export default postMessage;
171 changes: 171 additions & 0 deletions src/app/classes/[cId]/[mId]/components/chatComponents/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
'use client';

import {useRef, useState, useEffect} from 'react';
import {useParams} from 'next/navigation';
import Image from 'next/image';
import {useRecoilValue} from 'recoil';
import userState from '@/src/recoil/atoms/userState';
import chatRoomAPI from '@/src/api/chatRoom';
import getUserInfo from '@/src/api/classUser/getUserInfo';
import ChatInput from '@/src/app/classes/[cId]/[mId]/components/subComponents/ChatInput';
import {User} from '@/src/interfaces/user';

interface UserInfo extends User {
id: number;
}

const ShowMain = () => {
const user = useRecoilValue(userState) as User;
const [message, setMessage] = useState('');
const [messages, setMessages] = useState<string[]>([]);
const [chatUsers, setChatUsers] = useState<{[key: string]: User}>({});
const params = useParams();
const getClassId = Number(params.cId);
const getScheduleId = 14;
const messagesEndRef = useRef<HTMLDivElement>(null);

const handleGetMessages = () => {
chatRoomAPI
.getMessages(getScheduleId)
.then(res => {
if (res && 'data' in res) {
const {data} = res;
setMessages(data || []);
} else {
console.error('응답에서 data를 찾을 수 없습니다.');
setMessages([]);
}
})
.catch(error => {
console.error('메시지를 가져오는 중 오류 발생:', error);
});
};

const handleGetLiveStart = () => {
const eventSource = chatRoomAPI.getLiveMessages(getScheduleId, message => {
setMessages(prevMessages => [...prevMessages, message]);
});

eventSource.onerror = function (event) {
console.error('EventSource failed:', event);
};

return eventSource;
};

const handleGetUserInfo = async (uid: number) => {
const userInfo = await getUserInfo(uid, getClassId);
return userInfo;
};

useEffect(() => {
if (message === '') return;
chatRoomAPI
.postMessage(getScheduleId, user.id.toString(), message)
.then(res => {
setMessage('');
console.log(res);
});
}, [message]);

useEffect(() => {
const fetchChatUsers = async () => {
const newChatUsers: {[id: string]: UserInfo} = {};
for (const msg of messages) {
const [id] = msg.split(': ');
if (!newChatUsers[id]) {
newChatUsers[id] = await handleGetUserInfo(Number(id));
}
}
setChatUsers(newChatUsers);
};

fetchChatUsers();
}, [messages]);

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

useEffect(() => {
const eventSource = handleGetLiveStart();
return () => {
eventSource.close();
};
}, [getScheduleId]);

useEffect(() => {
handleGetMessages();
}, [getScheduleId]);

return (
<div className="flex justify-center mt-10">
<div className="w-full max-w-md">
<div className="w-full mt-2">
<div className="border-4 rounded-lg border-gray-500 p-2 h-[550px] overflow-auto">
<p className="text-center inline-block px-4 py-2 text-sm text-white bg-violet-300 rounded-lg w-full">
최상단 채팅 내용입니다
</p>
{messages.map((msg, index) => {
const [id, message] = msg.split(': ');
const chatUser = chatUsers[id];
if (!chatUser) {
return null;
}
return (
<div
key={index}
className={
Number(id) === user.id
? 'flex justify-end items-start'
: 'flex justify-start items-start'
}
>
{Number(id) !== user.id && (
<div className="flex items-center mt-2">
<div className="rounded-full w-8 h-8 overflow-hidden max-w-20">
<Image
src={chatUser.image || user.image}
alt={'userImage'}
width={40}
height={40}
/>
</div>
<div className="ml-2">
<p>{chatUser.nickname || user.name}</p>
<p
className={
'inline-block px-3 py-2 text-sm text-white bg-gray-500 rounded-lg max-w-72'
}
>
{message}
</p>
</div>
</div>
)}
{Number(id) === user.id && (
<div className="mt-2">
<p
className={
'inline-block px-4 py-2 text-sm text-white bg-blue-400 rounded-lg max-w-72'
}
>
{message}
</p>
</div>
)}
</div>
);
})}
<div ref={messagesEndRef} />
</div>
</div>
<ChatInput setMsg={setMessage} />
</div>
</div>
);
};

export default ShowMain;
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React from 'react';
import React, {useState} from 'react';
import LiveClass from './LiveClass';
import {useParams} from 'next/navigation';
import {useRecoilValue} from 'recoil';
import {User} from '@/src/interfaces/user';
import userState from '@/src/recoil/atoms/userState';
import ShowMain from '../chatComponents/page';

const ManageSubContainer: React.FC = () => {
const [showMain, setShowMain] = useState(false);
const {cId} = useParams<{cId: string}>();
const classId = parseInt(cId, 10);
const user = useRecoilValue(userState) as User;
Expand All @@ -17,6 +19,17 @@ const ManageSubContainer: React.FC = () => {
return (
<div>
<LiveClass classId={classId} userId={user.id} />
<button
onClick={() => setShowMain(!showMain)}
className="fixed top-1 right-4 z-50 bg-primary text-primary-foreground rounded-full p-3 shadow-lg hover:bg-primary/90"
>
{showMain ? '💬' : '💬'}
</button>
{showMain && (
<div className="fixed top-0 right-0 w-96 h-full bg-white shadow-lg z-10">
<ShowMain />
</div>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import PromptChat from './PromptChat';
import Storage from './Storage';
import materialState from '@/src/recoil/atoms/materialState';
import '@/src/styles/variable.css';
import ShowMain from '../chatComponents/page';

const SubContainer: React.FC = () => {
const TABS = ['프롬프트창', '저장목록'];
const [activeTab, setActiveTab] = useState(TABS[0]);
const [showMain, setShowMain] = useState(false);
const params = useParams<{cId: string}>();
const material = useRecoilValue(materialState);

Expand Down Expand Up @@ -39,6 +41,17 @@ const SubContainer: React.FC = () => {
<div className="subContainer">
<TabsMapping activeTab={activeTab} tabMapping={tabMapping} />
</div>
<button
onClick={() => setShowMain(!showMain)}
className="fixed top-1 right-4 z-50 bg-primary text-primary-foreground rounded-full p-3 shadow-lg hover:bg-primary/90"
>
{showMain ? '💬' : '💬'}
</button>
{showMain && (
<div className="fixed top-0 right-0 w-96 h-full bg-white shadow-lg z-10">
<ShowMain />
</div>
)}
</div>
);
};
Expand Down
38 changes: 23 additions & 15 deletions src/app/classes/components/_class/modal/ClassJoin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@

import React, {useState} from 'react';
import Image from 'next/image';
import getCheckClassSecret from '@/src/api/classCode/getCheckClassSecret';
import getVerifySecret from '@/src/api/classCode/getVerifySecret';
import putUserName from '@/src/api/classUser/putUserName';
import {ModalProps} from '@/src/interfaces/_class/modal';
import icons from '@/public/svgs/_class';

const ClassJoin = ({setActiveModalId, setIsModalOpen}: ModalProps) => {
const ClassJoin = ({setActiveModalId, uid, name}: ModalProps) => {
const [inputValue, setInputValue] = useState('');
const [password, setPassword] = useState('');

const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
};
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
};

const handleSubmit = async (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
Expand All @@ -21,19 +26,15 @@ const ClassJoin = ({setActiveModalId, setIsModalOpen}: ModalProps) => {
alert('클래스 코드를 입력해주세요');
return;
}
try {
const res = await getCheckClassSecret(inputValue);
console.log(res);
if (!res.secretExists) {
alert('이 클래스는 비밀번호가 설정되어있지 않습니다');
return;
}
setActiveModalId('');
if (setIsModalOpen) {
setIsModalOpen(true);
if (uid && name) {
try {
const res = await getVerifySecret(inputValue, password, uid);
await putUserName(uid, res.cid, name);
alert('신청 완료되었습니다.');
setActiveModalId('');
} catch (error: unknown) {
alert('잘못된 요청입니다.');
}
} catch (error: unknown) {
alert('잘못된 요청입니다.');
}
};

Expand Down Expand Up @@ -75,13 +76,20 @@ const ClassJoin = ({setActiveModalId, setIsModalOpen}: ModalProps) => {
</div>
<div className="mt-3">
<input
className="mt-3 w-full inline-flex justify-center rounded-md border ring-gray-100 shadow-sm px-4 py-2 bg-white-50 text-base font-medium hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:bg-gray-50 focus:ring-gray-100 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
className="mt-3 w-full inline-flex justify-center rounded-md border ring-gray-100 shadow-sm px-4 py-2 bg-white-50 text-base font-medium hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:bg-gray-50 focus:ring-gray-100"
type="text"
placeholder="클래스 코드를 입력해주세요"
value={inputValue}
onChange={handleInputChange}
required
/>
<input
type="password"
className="mt-3 w-full inline-flex justify-center rounded-md border ring-gray-100 shadow-sm px-4 py-2 bg-white-50 text-base font-medium hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:bg-gray-50 focus:ring-gray-100"
placeholder="비밀번호를 입력해주세요"
value={password}
onChange={handlePasswordChange}
/>
</div>
</div>
</div>
Expand Down

0 comments on commit b5a36dd

Please sign in to comment.