Skip to content

Commit

Permalink
Merge pull request #180 from CS3219-AY2425S1/lynn-chat
Browse files Browse the repository at this point in the history
  • Loading branch information
lynnlow175 authored Nov 3, 2024
2 parents ce210c3 + e9799ae commit 8c807f4
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 96 deletions.
15 changes: 15 additions & 0 deletions backend/collaboration-service/src/controllers/collab.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ export async function getSession(request: Request, response: Response): Promise<
response.status(200).json(session).send()
}

export async function getChatHistory(request: Request, response: Response): Promise<void> {
const id = request.params.id

// Obtains session by _id
const session = await getSessionById(id)

if (!session) {
response.status(404).json(`Session with id ${id} does not exist!`).send()
return
}

// Send retrieved data
response.status(200).json(session.chatHistory).send()
}

export async function submitCode(request: ITypedBodyRequest<SubmissionRequestDto>, response: Response): Promise<void> {
const submissionRequestDto = SubmissionRequestDto.fromRequest(request)
const requestErrors = await submissionRequestDto.validate()
Expand Down
3 changes: 2 additions & 1 deletion backend/collaboration-service/src/routes/collab.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Router } from 'express'
import passport from 'passport'
import { createSessionRequest, getSession, submitCode } from '../controllers/collab.controller'
import { createSessionRequest, getChatHistory, getSession, submitCode } from '../controllers/collab.controller'

const router = Router()

Expand All @@ -9,6 +9,7 @@ router.use(passport.authenticate('jwt', { session: false }))
// To change this route to enable retrival of sessions with pagination
router.put('/', createSessionRequest)
router.get('/:id', getSession)
router.get('/chat/:id', getChatHistory)
router.post('/submit', submitCode)

export default router
16 changes: 14 additions & 2 deletions backend/collaboration-service/src/services/socketio.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import loggerUtil from '../common/logger.util'
import { Server as IOServer, Socket } from 'socket.io'
import { completeCollaborationSession } from './collab.service'
import { updateLanguage } from '../models/collab.repository'
import { updateChatHistory, updateLanguage } from '../models/collab.repository'
import { LanguageMode } from '../types/LanguageMode'
import { ChatModel } from '../types'

export class WebSocketConnection {
private io: IOServer
Expand All @@ -26,6 +27,11 @@ export class WebSocketConnection {
}
})

socket.on('send_message', (data: ChatModel) => {
this.io.to(data.roomId).emit('receive_message', data)
updateChatHistory(data.roomId, data)
})

socket.on('change-language', async (language: string) => {
this.io.to(roomId).emit('update-language', language)
this.languages.set(roomId, language)
Expand All @@ -36,7 +42,13 @@ export class WebSocketConnection {
const room = this.io.sockets.adapter.rooms.get(roomId)
socket.leave(roomId)
if (!this.isUserInRoom(roomId, name)) {
this.io.to(roomId).emit('user-disconnected', name)
const m: ChatModel = {
senderId: '',
message: name,
createdAt: new Date(),
roomId: roomId,
}
this.io.to(roomId).emit('user-disconnected', m)
loggerUtil.info(`User ${name} disconnected from room ${roomId}`)
}
if (!room) {
Expand Down
1 change: 1 addition & 0 deletions backend/collaboration-service/src/types/IChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export class ChatModel implements IChat {
senderId: string
message: string
createdAt: Date
roomId: string
}
132 changes: 132 additions & 0 deletions frontend/pages/code/[id]/chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { FC, RefObject, useEffect, useRef, useState } from 'react'

import { useSession } from 'next-auth/react'
import { useRouter } from 'next/router'
import * as socketIO from 'socket.io-client'
import { getChatHistory } from '@/services/collaboration-service-api'
import { IChat } from '@/types/collaboration-api'

const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true }).toUpperCase()
}

const Chat: FC<{ socketRef: RefObject<socketIO.Socket | null> }> = ({ socketRef }) => {
const [chatData, setChatData] = useState<IChat[]>()
const chatEndRef = useRef<HTMLDivElement | null>(null)
const { data: session } = useSession()
const router = useRouter()
const [value, setValue] = useState('')
const { id: roomId } = router.query

useEffect(() => {
;(async () => {
const matchId = router.query.id as string
if (!matchId) {
return
}
const response = await getChatHistory(matchId).catch((_) => {
router.push('/')
})
console.log('Chat history', response)
setChatData(response)
})()
}, [router])

useEffect(() => {
if (socketRef?.current) {
socketRef.current.on('receive_message', (data: IChat) => {
console.log('Got a msg', data)
setChatData((prev) => {
return [...(prev ?? []), data]
})
})
}
}, [socketRef])

const getChatBubbleFormat = (currUser: string, type: 'label' | 'text') => {
let format = ''
if (currUser === session?.user.username) {
format = 'items-end ml-5'
// Add more format based on the type
if (type === 'text') {
format += ' bg-theme-600 rounded-xl text-white'
}
} else {
format = 'items-start text-left mr-5'
// Add more format based on the type
if (type === 'text') {
format += ' bg-slate-100 rounded-xl p-2 text-slate-900'
}
}

return format
}

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && e.currentTarget.value.trim() !== '') {
handleSendMessage(e.currentTarget.value)
e.currentTarget.value = ''
}
}

const handleSendMessage = (message: string) => {
if (!session || !socketRef?.current) return

if (message.trim()) {
const msg: IChat = {
message: message,
senderId: session.user.username,
createdAt: new Date(),
roomId: roomId as string,
}
socketRef.current.emit('send_message', msg)
}
setValue('')
}

useEffect(() => {
if (chatEndRef.current) {
chatEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [chatData])

return (
<>
<div className="overflow-y-auto p-3 pb-0">
{!!chatData?.length &&
Object.values(chatData).map((chat, index) => (
<div
key={index}
className={`flex flex-col gap-1 mb-5 ${getChatBubbleFormat(chat.senderId, 'label')}`}
>
<div className="flex items-center gap-2">
<h4 className="text-xs font-medium">{chat.senderId}</h4>
<span className="text-xs text-slate-400">
{formatTimestamp(chat.createdAt.toString())}
</span>
</div>
<div
className={`text-sm py-2 px-3 text-balance break-words w-full ${getChatBubbleFormat(chat.senderId, 'text')}`}
>
{chat.message}
</div>
</div>
))}
<div ref={chatEndRef}></div>
</div>
<div className="m-3 px-3 py-1 border-[1px] rounded-xl text-sm">
<input
type="text"
className="w-full bg-transparent border-none focus:outline-none"
placeholder="Send a message..."
onKeyDown={handleKeyDown}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
</>
)
}

export default Chat
96 changes: 3 additions & 93 deletions frontend/pages/code/[id]/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EndIcon, PlayIcon, SubmitIcon } from '@/assets/icons'
import { ITestcase, LanguageMode, getCodeMirrorLanguage } from '@/types'
import { mockChatData, mockTestCaseData, mockUserData } from '@/mock-data'
import { mockTestCaseData } from '@/mock-data'
import { useEffect, useRef, useState } from 'react'

import { Button } from '@/components/ui/button'
Expand All @@ -18,51 +18,20 @@ import { Category, IMatch, SortedComplexity } from '@repo/user-types'
import { useSession } from 'next-auth/react'
import { getMatchDetails } from '@/services/matching-service-api'
import { convertSortedComplexityToComplexity } from '@repo/question-types'
import Chat from './chat'
import io, { Socket } from 'socket.io-client'
import UserAvatar from '@/components/customs/custom-avatar'
import { toast } from 'sonner'

interface ICollaborator {
name: string
email: string
}

const userData: ICollaborator = mockUserData
const initialChatData = mockChatData
const testCasesData: ITestcase[] = mockTestCaseData

const formatQuestionCategories = (cat: Category[]) => {
return cat.join(', ')
}

const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true }).toUpperCase()
}

const getChatBubbleFormat = (currUser: ICollaborator, type: 'label' | 'text') => {
let format = ''
if (currUser.email === userData.email) {
format = 'items-end ml-5'
// Add more format based on the type
if (type === 'text') {
format += ' bg-theme-600 rounded-xl text-white'
}
} else {
format = 'items-start text-left mr-5'
// Add more format based on the type
if (type === 'text') {
format += ' bg-slate-100 rounded-xl p-2 text-slate-900'
}
}

return format
}

export default function Code() {
const router = useRouter()
const [isChatOpen, setIsChatOpen] = useState(true)
const [chatData, setChatData] = useState(initialChatData)
const { id } = router.query
const [editorLanguage, setEditorLanguage] = useState<LanguageMode>(LanguageMode.Javascript)
const testTabs = ['Testcases', 'Test Results']
Expand Down Expand Up @@ -128,37 +97,10 @@ export default function Code() {
}
}, [])

// Ref for autoscroll the last chat message
const chatEndRef = useRef<HTMLDivElement | null>(null)

const toggleChat = () => {
setIsChatOpen(!isChatOpen)
}

// TODO: create message service to handle messages
const sendMessage = (message: string) => {
const newMessage = {
user: userData,
message,
timestamp: new Date().toISOString(),
}
setChatData([...chatData, newMessage])
}

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && e.currentTarget.value.trim() !== '') {
sendMessage(e.currentTarget.value)
e.currentTarget.value = ''
}
}

// Scroll to the bottom of the chat box when new messages are added
useEffect(() => {
if (chatEndRef.current) {
chatEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [chatData])

const handleLanguageModeSelect = (value: string) => {
socketRef.current?.emit('change-language', value)
}
Expand Down Expand Up @@ -228,39 +170,7 @@ export default function Code() {
/>
</Button>
</div>
{isChatOpen && (
<>
<div className="overflow-y-auto p-3 pb-0">
{chatData.map((chat, index) => (
<div
key={index}
className={`flex flex-col gap-1 mb-5 ${getChatBubbleFormat(chat.user, 'label')}`}
>
<div className="flex items-center gap-2">
<h4 className="text-xs font-medium">{chat.user.name}</h4>
<span className="text-xs text-slate-400">
{formatTimestamp(chat.timestamp)}
</span>
</div>
<div
className={`text-sm py-2 px-3 text-balance break-words w-full ${getChatBubbleFormat(chat.user, 'text')}`}
>
{chat.message}
</div>
</div>
))}
<div ref={chatEndRef}></div>
</div>
<div className="m-3 px-3 py-1 border-[1px] rounded-xl text-sm">
<input
type="text"
className="w-full bg-transparent border-none focus:outline-none"
placeholder="Send a message..."
onKeyDown={handleKeyDown}
/>
</div>
</>
)}
{isChatOpen && <Chat socketRef={socketRef} />}
</div>
</section>
<section className="w-2/3 flex flex-col h-fullscreen">
Expand Down
8 changes: 8 additions & 0 deletions frontend/services/collaboration-service-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,11 @@ export const createCollabSession = async (data: ICollabSession): Promise<number
console.error(error)
}
}

export const getChatHistory = async (matchId: string): Promise<any | undefined> => {
try {
return axiosInstance.get(`/collab/chat/${matchId}`)
} catch (error) {
console.error(error)
}
}
1 change: 1 addition & 0 deletions frontend/types/collaboration-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export interface IChat {
senderId: string
message: string
createdAt: Date
roomId: string
}

0 comments on commit 8c807f4

Please sign in to comment.