diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 387d9a93..f277c59a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,10 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { ThemeProvider } from 'styled-components'; import { theme } from './styles/theme'; -import { MainPage, ClientPage, HostPage } from './pages'; +import { ClientPage, ErrorPage, HostPage, MainPage, ReplayPage } from './pages'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '@apis/index'; import withUserId from '@hocs/withUserId'; -import ReplayPage from '@pages/ReplayPage'; function AppComponent() { return ( @@ -19,12 +18,10 @@ function AppComponent() { > } /> - } /> } /> - } /> } /> } /> - } /> + } /> diff --git a/frontend/src/apis/checkLiveExist.ts b/frontend/src/apis/checkLiveExist.ts new file mode 100644 index 00000000..99de0460 --- /dev/null +++ b/frontend/src/apis/checkLiveExist.ts @@ -0,0 +1,13 @@ +import { AxiosResponse } from 'axios'; +import { fetchInstance } from '.'; +import { LiveExistenceResponse } from '@type/live'; + +export const checkLiveExist = async ({ liveId }: { liveId: string }): Promise => { + const response: AxiosResponse = await fetchInstance().get('/streams/existence', { + params: { + sessionKey: liveId + } + }); + + return response.data; +}; diff --git a/frontend/src/apis/checkReplayExist.ts b/frontend/src/apis/checkReplayExist.ts new file mode 100644 index 00000000..ffcac144 --- /dev/null +++ b/frontend/src/apis/checkReplayExist.ts @@ -0,0 +1,13 @@ +import { AxiosResponse } from 'axios'; +import { fetchInstance } from '.'; +import { ReplayExistenceResponse } from '@type/replay'; + +export const checkReplayExist = async ({ videoId }: { videoId: string }): Promise => { + const response: AxiosResponse = await fetchInstance().get('/replay/existence', { + params: { + videoId + } + }); + + return response.data; +}; diff --git a/frontend/src/apis/fetchChatRule.ts b/frontend/src/apis/fetchChatRule.ts new file mode 100644 index 00000000..38205857 --- /dev/null +++ b/frontend/src/apis/fetchChatRule.ts @@ -0,0 +1,17 @@ +import { AxiosResponse } from 'axios'; +import { fetchInstance } from '.'; + +export type ChatRuleResponse = { + notice: string; + channelName: string; +}; + +export const fetchChatRule = async ({ sessionKey }: { sessionKey: string }): Promise => { + const response: AxiosResponse = await fetchInstance().get('/streams/notice', { + params: { + sessionKey + } + }); + + return response.data; +}; diff --git a/frontend/src/apis/fetchLive.ts b/frontend/src/apis/fetchLive.ts index f1ff0807..102c5501 100644 --- a/frontend/src/apis/fetchLive.ts +++ b/frontend/src/apis/fetchLive.ts @@ -1,17 +1,18 @@ import { AxiosResponse } from 'axios'; import { fetchInstance } from '.'; -import { ClientLive } from '@type/live'; +import { ClientLiveResponse } from '@type/live'; -type ClientLiveResponse = { - info: ClientLive; -}; - -export const fetchLive = async ({ liveId }: { liveId: string }): Promise => { - const response: AxiosResponse = await fetchInstance().get('/streams/live', { - params: { - liveId +export const fetchLive = async ({ liveId }: { liveId: string }): Promise => { + try { + const response: AxiosResponse = await fetchInstance().get('/streams/live', { + params: { liveId } + }); + return response.data; + } catch (error: any) { + if (error.response && error.response.status === 400) { + console.log('error', error); + throw error; } - }); - - return response.data.info; + throw error; + } }; diff --git a/frontend/src/apis/fetchReplay.ts b/frontend/src/apis/fetchReplay.ts index 834518af..01c80742 100644 --- a/frontend/src/apis/fetchReplay.ts +++ b/frontend/src/apis/fetchReplay.ts @@ -1,17 +1,20 @@ import { AxiosResponse } from 'axios'; import { fetchInstance } from '.'; -import { ReplayStream } from '@type/replay'; +import { ClientReplayResponse } from '@type/replay'; -type ReplayStreamResponse = { - info: ReplayStream; -}; - -export const fetchReplay = async ({ videoId }: { videoId: string }): Promise => { - const response: AxiosResponse = await fetchInstance().get('/replay/video', { - params: { - videoId +export const fetchReplay = async ({ videoId }: { videoId: string }): Promise => { + try { + const response: AxiosResponse = await fetchInstance().get('/replay/video', { + params: { + videoId + } + }); + return response.data; + } catch (error: any) { + if (error.response && error.response.status === 400) { + console.log('error', error); + throw error; } - }); - - return response.data.info; + throw error; + } }; diff --git a/frontend/src/apis/queries/chat/useFetchChatRule.ts b/frontend/src/apis/queries/chat/useFetchChatRule.ts new file mode 100644 index 00000000..1ebf19c1 --- /dev/null +++ b/frontend/src/apis/queries/chat/useFetchChatRule.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { ChatRuleResponse, fetchChatRule } from '@apis/fetchChatRule'; + +export const useFetchChatRule = ({ sessionKey }: { sessionKey: string }) => { + return useQuery({ + queryKey: ['chatRule'], + queryFn: () => fetchChatRule({ sessionKey }), + refetchOnWindowFocus: false + }); +}; diff --git a/frontend/src/apis/queries/client/useCheckLiveExist.ts b/frontend/src/apis/queries/client/useCheckLiveExist.ts new file mode 100644 index 00000000..75048221 --- /dev/null +++ b/frontend/src/apis/queries/client/useCheckLiveExist.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; + +import { checkLiveExist } from '@apis/checkLiveExist'; +import { LiveExistenceResponse } from '@type/live'; + +export const useCheckLiveExist = ({ liveId }: { liveId: string }) => { + return useQuery({ + queryKey: ['checkLiveExist'], + queryFn: () => checkLiveExist({ liveId }), + refetchOnWindowFocus: false, + initialData: { existed: true } + }); +}; diff --git a/frontend/src/apis/queries/client/useFetchLive.ts b/frontend/src/apis/queries/client/useFetchLive.ts index a5c7a4e4..38e22ab4 100644 --- a/frontend/src/apis/queries/client/useFetchLive.ts +++ b/frontend/src/apis/queries/client/useFetchLive.ts @@ -1,12 +1,14 @@ import { useQuery } from '@tanstack/react-query'; - import { fetchLive } from '@apis/fetchLive'; -import { ClientLive } from '@type/live'; +import { ClientLive, ClientLiveResponse } from '@type/live'; export const useClientLive = ({ liveId }: { liveId: string }) => { - return useQuery({ + return useQuery({ queryKey: ['clientLive'], - queryFn: () => fetchLive({ liveId: liveId }), - refetchOnWindowFocus: false + queryFn: () => fetchLive({ liveId }), + refetchOnWindowFocus: false, + initialData: { info: {} as ClientLive }, + throwOnError: true, + retry: 0, }); }; diff --git a/frontend/src/apis/queries/main/useFetchMainLive.ts b/frontend/src/apis/queries/main/useFetchMainLive.ts index e7ec23c8..466da53c 100644 --- a/frontend/src/apis/queries/main/useFetchMainLive.ts +++ b/frontend/src/apis/queries/main/useFetchMainLive.ts @@ -7,6 +7,6 @@ export const useMainLive = () => { return useSuspenseQuery({ queryKey: ['mainLive'], queryFn: fetchMainLive, - refetchOnWindowFocus: false, + refetchOnWindowFocus: false }); }; diff --git a/frontend/src/apis/queries/replay/useCheckReplayExist.ts b/frontend/src/apis/queries/replay/useCheckReplayExist.ts new file mode 100644 index 00000000..12a03e5e --- /dev/null +++ b/frontend/src/apis/queries/replay/useCheckReplayExist.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; + +import { checkReplayExist } from '@apis/checkReplayExist'; +import { ReplayExistenceResponse } from '@type/replay'; + +export const useCheckReplayExist = ({ videoId }: { videoId: string }) => { + return useQuery({ + queryKey: ['checkReplayExist'], + queryFn: () => checkReplayExist({ videoId }), + refetchOnWindowFocus: false, + initialData: { existed: true } + }); +}; diff --git a/frontend/src/apis/queries/replay/useFetchReplay.ts b/frontend/src/apis/queries/replay/useFetchReplay.ts index 24d89d32..d84143a4 100644 --- a/frontend/src/apis/queries/replay/useFetchReplay.ts +++ b/frontend/src/apis/queries/replay/useFetchReplay.ts @@ -1,12 +1,15 @@ import { useQuery } from '@tanstack/react-query'; import { fetchReplay } from '@apis/fetchReplay'; -import { ReplayStream } from '@type/replay'; +import { ClientReplayResponse, ReplayStream } from '@type/replay'; export const useClientReplay = ({ videoId }: { videoId: string }) => { - return useQuery({ + return useQuery({ queryKey: ['clientReplay'], - queryFn: () => fetchReplay({ videoId: videoId }), - refetchOnWindowFocus: false + queryFn: () => fetchReplay({ videoId }), + refetchOnWindowFocus: false, + initialData: { info: {} as ReplayStream }, + throwOnError: true, + retry: 0 }); }; diff --git a/frontend/src/assets/icons/user-block.svg b/frontend/src/assets/icons/user-block.svg new file mode 100644 index 00000000..a9e56a1e --- /dev/null +++ b/frontend/src/assets/icons/user-block.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/warning_icon.svg b/frontend/src/assets/icons/warning_icon.svg new file mode 100644 index 00000000..5ed19bd8 --- /dev/null +++ b/frontend/src/assets/icons/warning_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/chat/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx index ca30396f..8b67d84f 100644 --- a/frontend/src/components/chat/ChatInput.tsx +++ b/frontend/src/components/chat/ChatInput.tsx @@ -3,9 +3,9 @@ import SpeechBubbleIcon from '@assets/icons/speech-bubble.svg'; import QuestionIcon from '@assets/icons/question.svg'; import SpeakerIcon from '@assets/icons/speaker.svg'; import SendIcon from '@assets/icons/send.svg'; -import { useRef, useEffect, useState, ChangeEvent, KeyboardEvent, memo } from 'react'; +import { useRef, useEffect, useState, KeyboardEvent, memo } from 'react'; import { CHATTING_SOCKET_SEND_EVENT, CHATTING_TYPES } from '@constants/chat'; -import { ChattingTypes } from '@type/chat'; +import { ChattingSendTypes } from '@type/chat'; import { getStoredId } from '@utils/id'; import { UserType } from '@type/user'; @@ -17,11 +17,10 @@ interface ChatInputProps { const INITIAL_TEXTAREA_HEIGHT = 20; -export const ChatInput = ({ worker, userType, roomId }: ChatInputProps) => { +const ChatInput = ({ worker, userType, roomId }: ChatInputProps) => { const [hasInput, setHasInput] = useState(false); const [isFocused, setIsFocused] = useState(false); - const [msgType, setMsgType] = useState(CHATTING_TYPES.NORMAL); - const [message, setMessage] = useState(''); + const [msgType, setMsgType] = useState(CHATTING_TYPES.NORMAL); const textareaRef = useRef(null); const userId = getStoredId(); @@ -38,8 +37,9 @@ export const ChatInput = ({ worker, userType, roomId }: ChatInputProps) => { }; const handleMessageSend = () => { - if (!worker || !message.trim()) return; + if (!worker || !textareaRef.current || !textareaRef.current.value.trim()) return; + const message = textareaRef.current.value.trim(); const eventMap = { [CHATTING_TYPES.NORMAL]: CHATTING_SOCKET_SEND_EVENT.NORMAL, [CHATTING_TYPES.QUESTION]: CHATTING_SOCKET_SEND_EVENT.QUESTION, @@ -58,19 +58,21 @@ export const ChatInput = ({ worker, userType, roomId }: ChatInputProps) => { }); resetTextareaHeight(); - setMessage(''); + textareaRef.current.value = ''; setHasInput(false); }; - const handleInputChange = (e: ChangeEvent) => { - const inputValue = e.target.value; + const handleInputChange = () => { + if (!textareaRef.current) return; - if (inputValue.length > 150) { + const value = textareaRef.current.value; + + if (value.length > 150) { + textareaRef.current.value = value.slice(0, 150); return; } - setMessage(e.target.value); - setHasInput(e.target.value.length > 0); + setHasInput(value.length > 0); }; const resetTextareaHeight = () => { @@ -146,11 +148,10 @@ export const ChatInput = ({ worker, userType, roomId }: ChatInputProps) => { const ChatInputArea = styled.textarea` width: 100%; min-height: 20px; + max-height: 40px; scrollbar-width: none; resize: none; border: none; @@ -193,7 +195,7 @@ const ChatInputArea = styled.textarea` ${({ theme }) => theme.tokenTypographys['display-medium16']}; background-color: transparent; white-space: normal; - line-height: 20px; + line-height: 23px; `; const InputBtn = styled.button` diff --git a/frontend/src/components/chat/ChatList.tsx b/frontend/src/components/chat/ChatList.tsx index f44bed52..d76b2d1e 100644 --- a/frontend/src/components/chat/ChatList.tsx +++ b/frontend/src/components/chat/ChatList.tsx @@ -1,59 +1,80 @@ import styled from 'styled-components'; import QuestionCard from './QuestionCard'; -import { memo, useContext, useEffect, useRef, useState } from 'react'; -import { MessageReceiveData } from '@type/chat'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { UserInfoData, MessageReceiveData } from '@type/chat'; import { CHATTING_TYPES } from '@constants/chat'; -import { ChatContext } from 'src/contexts/chatContext'; -import NoticeCard from './NoticeCard'; import ChatAutoScroll from './ChatAutoScroll'; import HostIconGreen from '@assets/icons/host_icon_green.svg'; +import { useChat } from '@contexts/chatContext'; export interface ChatListProps { messages: MessageReceiveData[]; } -const ChatItemWrapper = memo(({ chat }: { chat: MessageReceiveData }) => { - if (chat.msgType === CHATTING_TYPES.QUESTION) { - return ( - - - - ); - } else if (chat.msgType === CHATTING_TYPES.NOTICE) { - return ( - - - 📢 - {chat.msg} - - - ); - } else { - return ( - - - {chat.owner === 'me' ? ( - 🧀 - ) : chat.owner === 'host' ? ( - - ) : null} - {chat.nickname} - {chat.msg} - - - ); +const ChatItemWrapper = memo( + ({ chat, onNicknameClick }: { chat: MessageReceiveData; onNicknameClick: (data: UserInfoData) => void }) => { + const { nickname, socketId, entryTime, owner } = chat; + const handleNicknameClick = () => onNicknameClick({ nickname, socketId, entryTime, owner }); + if (chat.msgType === CHATTING_TYPES.QUESTION) { + return ( + + + + ); + } else if (chat.msgType === CHATTING_TYPES.NOTICE) { + return ( + + + 📢 + {chat.msg} + + + ); + } else if (chat.msgType === CHATTING_TYPES.EXCEPTION) { + return ( + + + 🚨 + {chat.msg} + + + ); + } else { + return ( + + + + {chat.owner === 'me' ? '🧀 ' : chat.owner === 'host' ? : null} + {chat.nickname} + + {chat.msg} + + + ); + } } -}); +); ChatItemWrapper.displayName = 'ChatItemWrapper'; const ChatList = ({ messages }: ChatListProps) => { - const { state } = useContext(ChatContext); const [isAtBottom, setIsAtBottom] = useState(true); const [currentChat, setCurrentChat] = useState(null); const chatListRef = useRef(null); + const { dispatch } = useChat(); + + const onNicknameClick = useCallback( + (data: UserInfoData) => { + dispatch({ + type: 'SET_SELECTED_USER', + payload: data + }); + }, + [dispatch] + ); + const checkIfAtBottom = () => { if (!chatListRef.current) return; const { scrollTop, scrollHeight, clientHeight } = chatListRef.current; @@ -83,15 +104,10 @@ const ChatList = ({ messages }: ChatListProps) => { {messages.map((chat, index) => ( - + ))} - {state.isNoticePopupOpen && ( - - - - )} ); }; @@ -99,24 +115,23 @@ const ChatList = ({ messages }: ChatListProps) => { export default ChatList; const ChatListSection = styled.div` + position: relative; display: flex; flex-direction: column; justify-content: flex-end; - position: relative; height: 100%; + overflow-y: hidden; `; const ChatListWrapper = styled.div` box-sizing: border-box; - position: absolute; max-height: 100%; width: 100%; display: flex; flex-direction: column; - padding: 50px 20px 0 20px; overflow-y: auto; + padding: 50px 20px 0 20px; scrollbar-width: none; - z-index: 100; `; const ChatItem = styled.div` @@ -144,6 +159,7 @@ const NormalChat = styled.div<{ $isHost: boolean; $pointColor: string }>` ${({ theme }) => theme.tokenTypographys['display-bold14']}; color: ${({ $pointColor }) => $pointColor}; margin-right: 8px; + cursor: pointer; } .chat_message { @@ -151,18 +167,19 @@ const NormalChat = styled.div<{ $isHost: boolean; $pointColor: string }>` line-height: 1.5; } + .user_name { + cursor: pointer; + padding: 2px; + border-radius: 5px; + &:hover { + background-color: #393939; + } + } + overflow-wrap: break-word; word-break: break-word; `; -const PopupWrapper = styled.div` - position: absolute; - bottom: 0; - left: 5%; - right: 5%; - z-index: 1000; -`; - const StyledIcon = styled.svg` width: 18px; height: 18px; diff --git a/frontend/src/components/chat/ChatQuestionSection.tsx b/frontend/src/components/chat/ChatQuestionSection.tsx index e3e3c839..e4ac2ee5 100644 --- a/frontend/src/components/chat/ChatQuestionSection.tsx +++ b/frontend/src/components/chat/ChatQuestionSection.tsx @@ -1,10 +1,11 @@ import { memo, useState, useCallback } from 'react'; import styled from 'styled-components'; import QuestionCard from './QuestionCard'; -import { MessageReceiveData, MessageSendData } from '@type/chat'; +import { MessageReceiveData, MessageSendData, UserInfoData } from '@type/chat'; import { CHATTING_SOCKET_SEND_EVENT } from '@constants/chat'; import { getStoredId } from '@utils/id'; import { UserType } from '@type/user'; +import { useChat } from '@contexts/chatContext'; export interface ChatQuestionSectionProps { questions: MessageReceiveData[]; @@ -38,6 +39,18 @@ const ChatQuestionSection = ({ questions, worker, userType, roomId }: ChatQuesti [worker, roomId, userId] ); + const { dispatch } = useChat(); + + const onNicknameClick = useCallback( + (data: UserInfoData) => { + dispatch({ + type: 'SET_SELECTED_USER', + payload: data + }); + }, + [dispatch] + ); + return ( @@ -51,18 +64,32 @@ const ChatQuestionSection = ({ questions, worker, userType, roomId }: ChatQuesti question={questions[0]} handleQuestionDone={handleQuestionDone} ellipsis={!expanded} + onNicknameClick={() => + onNicknameClick({ + nickname: questions[0].nickname, + socketId: questions[0].socketId, + entryTime: questions[0].entryTime, + owner: questions[0].owner + }) + } /> {expanded && - questions - .slice(1) - .map((question) => ( - - ))} + questions.slice(1).map((question) => ( + + onNicknameClick({ + nickname: question.nickname, + socketId: question.socketId, + entryTime: question.entryTime, + owner: question.owner + }) + } + /> + ))} )} diff --git a/frontend/src/components/chat/ChatRoomLayout.tsx b/frontend/src/components/chat/ChatRoomLayout.tsx index 0cda7c10..84a21174 100644 --- a/frontend/src/components/chat/ChatRoomLayout.tsx +++ b/frontend/src/components/chat/ChatRoomLayout.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback, useContext, useState } from 'react'; import styled from 'styled-components'; import ChatHeader from './ChatHeader'; @@ -9,6 +9,9 @@ import ChatIcon from '@assets/icons/chat_icon.svg'; import { useChatRoom } from '@hooks/useChatRoom'; import { UserType } from '@type/user'; import { getStoredId } from '@utils/id'; +import NoticeCard from './NoticeCard'; +import { ChatContext } from '@contexts/chatContext'; +import UserInfoCard from './UserInfoCard'; interface ChatRoomLayoutProps { userType: UserType; @@ -21,6 +24,8 @@ const ChatRoomLayout = ({ userType, roomId }: ChatRoomLayoutProps) => { const userId = getStoredId(); const { worker, messages, questions } = useChatRoom(roomId as string, userId); + const { state } = useContext(ChatContext); + const handleCloseChatRoom = useCallback(() => { setIsChatRoomVisible(false); }, []); @@ -42,6 +47,18 @@ const ChatRoomLayout = ({ userType, roomId }: ChatRoomLayoutProps) => { + {state.isNoticePopupOpen && ( + + + + )} + + {state.isUserInfoPopupOpen && ( + + + + )} + @@ -68,6 +85,7 @@ const StyledChatIcon = styled(ChatIcon)` `; const ChatRoomContainer = styled.aside<{ $isVisible: boolean }>` + position: relative; display: ${({ $isVisible }) => ($isVisible ? 'flex' : 'none')}; flex-direction: column; height: 100%; @@ -80,3 +98,11 @@ const ChatRoomContainer = styled.aside<{ $isVisible: boolean }>` const ChatInputContainer = styled.div` padding: 10px 20px; `; + +const PopupWrapper = styled.div` + position: absolute; + bottom: 80px; + left: 5%; + right: 5%; + z-index: 1000; +`; diff --git a/frontend/src/components/chat/NoticeCard.tsx b/frontend/src/components/chat/NoticeCard.tsx index 4ff86478..eefdd598 100644 --- a/frontend/src/components/chat/NoticeCard.tsx +++ b/frontend/src/components/chat/NoticeCard.tsx @@ -1,14 +1,17 @@ import styled from 'styled-components'; import CloseIcon from '@assets/icons/close.svg'; -import { useContext } from 'react'; +import { memo, useCallback, useContext } from 'react'; import { ChatContext } from 'src/contexts/chatContext'; +import { useFetchChatRule } from '@apis/queries/chat/useFetchChatRule'; -export const NoticeCard = () => { +export const NoticeCard = ({ sessionKey }: { sessionKey: string }) => { const { dispatch } = useContext(ChatContext); - const toggleSettings = () => { + const toggleSettings = useCallback(() => { dispatch({ type: 'TOGGLE_ANNOUNCEMENT_POPUP' }); - }; + }, [dispatch]); + + const { data: noticeInfo } = useFetchChatRule({ sessionKey }); return ( @@ -17,10 +20,10 @@ export const NoticeCard = () => {
- 네이버 부스트 캠프 + {noticeInfo?.channelName} 님의
-
컨퍼런스 공지 📢
+
채팅 규칙 📢
@@ -28,17 +31,11 @@ export const NoticeCard = () => { - - - 질문은 질문 채팅으로 부탁드립니다 -
- 컨퍼런스 보러와주셔서 감사합니다 -
- 컨퍼런스 보러와주셔서 감사합니다 -
- 컨퍼런스 보러와주셔서 감사합니다 -
- 컨퍼런스 보러와주셔서 감사합니다 -
+ {noticeInfo?.notice}
); }; -export default NoticeCard; +export default memo(NoticeCard); const NoticeCardContainer = styled.div` display: flex; @@ -113,5 +110,7 @@ const NoticeMessage = styled.p` margin-top: 10px; max-height: 170px; overflow-y: auto; - ${({ theme }) => theme.tokenTypographys['display-bold14']} + ${({ theme }) => theme.tokenTypographys['display-bold14']}; + white-space: pre-line; + word-break: keep-all; `; diff --git a/frontend/src/components/chat/QuestionCard.tsx b/frontend/src/components/chat/QuestionCard.tsx index cd150b36..006e5039 100644 --- a/frontend/src/components/chat/QuestionCard.tsx +++ b/frontend/src/components/chat/QuestionCard.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useRef } from 'react'; +import { memo, useEffect, useMemo, useRef, useCallback } from 'react'; import styled from 'styled-components'; import CheckIcon from '@assets/icons/check.svg'; import { MessageReceiveData } from '@type/chat'; @@ -10,9 +10,10 @@ interface QuestionCardProps { question: MessageReceiveData; handleQuestionDone?: (questionId: number) => void; ellipsis?: boolean; + onNicknameClick: () => void; } -export const QuestionCard = ({ type, question, handleQuestionDone, ellipsis = false }: QuestionCardProps) => { +const QuestionCard = ({ type, question, handleQuestionDone, onNicknameClick, ellipsis = false }: QuestionCardProps) => { const startDateFormat = useMemo(() => new Date(question.msgTime), [question.msgTime]); const nowRef = useRef(new Date()); @@ -36,11 +37,21 @@ export const QuestionCard = ({ type, question, handleQuestionDone, ellipsis = fa const timeElement = useRef(null); + const handleQuestionDoneMemoized = useCallback(() => { + if (handleQuestionDone) { + handleQuestionDone(question.questionId as number); + } + }, [handleQuestionDone, question.questionId]); + + const onNicknameClickMemoized = useCallback(() => { + onNicknameClick(); + }, [onNicknameClick]); + return ( - + {question.nickname} @@ -48,7 +59,7 @@ export const QuestionCard = ({ type, question, handleQuestionDone, ellipsis = fa {type === 'host' && handleQuestionDone && ( - handleQuestionDone(question.questionId as number)}> + )} @@ -59,7 +70,14 @@ export const QuestionCard = ({ type, question, handleQuestionDone, ellipsis = fa ); }; -export default memo(QuestionCard); +// shouldComponentUpdate를 내부에서 사용할 수 있도록 memo 사용 +export default memo(QuestionCard, (prevProps, nextProps) => { + return ( + prevProps.question.questionId === nextProps.question.questionId && + prevProps.type === nextProps.type && + prevProps.ellipsis === nextProps.ellipsis + ); +}); const QuestionCardContainer = styled.div` display: flex; @@ -86,7 +104,12 @@ const QuestionInfo = styled.div` align-items: end; gap: 12px; .name_info { - ${({ theme }) => theme.tokenTypographys['display-bold12']} + border-radius: 7px; + ${({ theme }) => theme.tokenTypographys['display-bold12']}; + cursor: pointer; + &:hover { + color: #bbbbbb; + } } .time_info { ${({ theme }) => theme.tokenTypographys['display-medium12']} diff --git a/frontend/src/components/chat/UserInfoCard.tsx b/frontend/src/components/chat/UserInfoCard.tsx new file mode 100644 index 00000000..2d06286a --- /dev/null +++ b/frontend/src/components/chat/UserInfoCard.tsx @@ -0,0 +1,162 @@ +import styled from 'styled-components'; +import CloseIcon from '@assets/icons/close.svg'; +import UserBlockIcon from '@assets/icons/user-block.svg'; +import { useChat } from 'src/contexts/chatContext'; +import { CHATTING_SOCKET_DEFAULT_EVENT } from '@constants/chat'; +import { getStoredId } from '@utils/id'; +import { UserType } from '@type/user'; +import { parseDate } from '@utils/parseDate'; +import { memo } from 'react'; +import { usePortal } from '@hooks/usePortal'; +import { useModal } from '@hooks/useModal'; +import ConfirmModal from '@components/common/ConfirmModal'; + +interface UserInfoCardProps { + worker: MessagePort | null; + roomId: string; + userType: UserType; +} + +export const UserInfoCard = ({ worker, roomId, userType }: UserInfoCardProps) => { + const { state, dispatch } = useChat(); + const { isOpen, closeModal, openModal } = useModal(); + const createPortal = usePortal(); + + const toggleSettings = () => { + dispatch({ type: 'CLOSE_USER_INFO_POPUP' }); + }; + + const { selectedUser } = state; + + const userId = getStoredId(); + + const onBan = () => { + if (!worker) return; + + worker.postMessage({ + type: CHATTING_SOCKET_DEFAULT_EVENT.BAN_USER, + payload: { + socketId: selectedUser?.socketId, + userId, + roomId + } + }); + + toggleSettings(); + }; + + return ( + + + + +
+ + {selectedUser?.owner === 'host' && '[호스트] '} + {selectedUser?.nickname} + + +
+
{parseDate(selectedUser?.entryTime as string)} 입장
+
+
+ + + +
+ {userType === 'host' && selectedUser?.owner === 'user' && ( + <> + + + 사용자 차단 + + + )} + {isOpen && + createPortal( + + )} +
+ ); +}; +export default memo(UserInfoCard); + +const UserInfoCardContainer = styled.div` + display: flex; + flex-direction: column; + padding: 20px; + gap: 13px; + border-radius: 7px; + box-shadow: 0px 4px 4px 0px #0d0d0da2; + background-color: #202224; + color: ${({ theme }) => theme.tokenColors['color-white']}; +`; + +const UserInfoCardHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: start; +`; + +const UserInfoCardWrapper = styled.div` + display: flex; + align-items: center; +`; + +const UserInfoCardArea = styled.div` + display: flex; + flex-direction: column; + margin-top: 5px; + .text_info { + ${({ theme }) => theme.tokenTypographys['display-bold16']} + color: ${({ theme }) => theme.tokenColors['text-strong']}; + } + .text_point { + color: ${({ theme }) => theme.tokenColors['brand-default']}; + } + .entry_time { + ${({ theme }) => theme.tokenTypographys['display-medium12']} + color: ${({ theme }) => theme.tokenColors['color-white']}; + } +`; + +const CloseBtn = styled.button` + color: ${({ theme }) => theme.tokenColors['text-strong']}; + :hover { + color: ${({ theme }) => theme.tokenColors['brand-default']}; + } +`; + +const StyledCloseIcon = styled(CloseIcon)` + width: 30px; + height: 30px; + cursor: pointer; +`; + +const StyledUserBlockIcon = styled(UserBlockIcon)` + width: 20px; + height: 20px; +`; + +const BanBtn = styled.button` + &:hover { + background-color: #313131; + } + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + gap: 3px; + border-radius: 7px; + background-color: #101010; + color: ${({ theme }) => theme.tokenColors['text-bold']}; + ${({ theme }) => theme.tokenTypographys['display-bold14']}; + cursor: pointer; +`; diff --git a/frontend/src/components/client/Chat.tsx b/frontend/src/components/client/Chat.tsx deleted file mode 100644 index 3d950ddc..00000000 --- a/frontend/src/components/client/Chat.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useState } from 'react'; -import styled from 'styled-components'; - -const Chat = () => { - const [chatVisible, setChatVisible] = useState(true); - - const handleChatVisible = () => { - setChatVisible(!chatVisible); - }; - - return ( - <> - {chatVisible && ( - -

채팅

- -
- )} - - ); -}; - -export default Chat; - -const ChatContainer = styled.section` - min-width: 350px; - border: 1px solid red; - padding: 10px 20px; - background-color: ${({ theme }) => theme.tokenColors['color-white']}; -`; diff --git a/frontend/src/components/client/ClientView.tsx b/frontend/src/components/client/ClientView.tsx index 86f74a45..94ae00ba 100644 --- a/frontend/src/components/client/ClientView.tsx +++ b/frontend/src/components/client/ClientView.tsx @@ -9,18 +9,18 @@ import { useClientLive } from '@queries/client/useFetchLive'; const ClientView = () => { const { id: liveId } = useParams(); - const { data: clientLiveData } = useClientLive({ liveId: liveId as string }); + const { data: clientLiveData } = useClientLive({ + liveId: liveId as string + }); - if (!clientLiveData) { - return
로딩 중...
; - } + const { info } = clientLiveData; return (

클라이언트 페이지

- - + +