Skip to content

Commit

Permalink
Merge pull request #28 from Nexters/feat/#18
Browse files Browse the repository at this point in the history
[Feat/#18] 채팅 페이지 개발
  • Loading branch information
Jxxunnn authored Jan 29, 2025
2 parents 5d4d9be + 21f03a4 commit 652af8d
Show file tree
Hide file tree
Showing 28 changed files with 963 additions and 192 deletions.
10 changes: 2 additions & 8 deletions src/app/chats/[chatId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import HeaderContent from "@/shared/components/HeaderContent";
import MainContent from "@/shared/components/MainContent";
import Chat from "@/chat/components/Chat";

export default function ChatPage() {
return (
<>
<HeaderContent>{null}</HeaderContent>
<MainContent>{null}</MainContent>
</>
);
return <Chat />;
}
19 changes: 7 additions & 12 deletions src/chat/apis/getChatMessagesByRoomId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,29 +24,24 @@ const schema = z.object({
answers: z.array(z.string()),
tarotName: TarotCardIdSchema.optional(),
tarotResultId: z.number().optional(),
}),
})
),
});

type ChatMessagesByRoomIdData = z.infer<typeof schema>;
export type ChatMessagesByRoomIdData = z.infer<typeof schema>;

const validate = (
data: ChatMessagesByRoomIdResponse,
): ChatMessagesByRoomIdData => {
const validate = (data: ChatMessagesByRoomIdResponse): ChatMessagesByRoomIdData => {
const validatedData = schema.parse(data);
return validatedData;
};

export const getChatMessagesByRoomId = (roomId: number) => {
return apiClient
.get<ChatMessagesByRoomIdResponse>(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/chat/room/messages`,
{
params: {
roomId,
},
.get<ChatMessagesByRoomIdResponse>(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/chat/room/messages`, {
params: {
roomId,
},
)
})
.then((res) => validate(res.data))
.catch((error) => {
console.error(error);
Expand Down
122 changes: 122 additions & 0 deletions src/chat/components/AcceptRejectButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useChatMessagesContext } from "@/chat/hooks/useChatMessagesStore";
import { useSendChatMessage } from "@/chat/hooks/useSendChatMesasge";
import { delay } from "@/shared/utils/delay";
import { useParams } from "next/navigation";
import { css } from "styled-components";
import ChipButton from "./ChipButton";

type Props = {
open: boolean;
};

export default function AcceptRejectButtons({ open }: Props) {
const { addMessage, deleteMessage } = useChatMessagesContext();
const { mutate: sendChatMessage, isPending: isSendingChatMessage } = useSendChatMessage();
const { chatId } = useParams<{ chatId: string }>();

const rejectMessage = "아니, 얘기 더 들어봐";
const acceptMessage = "좋아! 타로 볼래";

if (!chatId) throw new Error("chatId가 Dynamic Route에서 전달 되어야 합니다.");

const handleAcceptClick = async () => {
addMessage({
messageId: Math.random(),
type: "USER_NORMAL",
sender: "USER",
answers: [acceptMessage],
});

await delay(500);

const loadingMessageId = Math.random();

addMessage({
messageId: loadingMessageId,
type: "SYSTEM_NORMAL_REPLY",
sender: "SYSTEM",
loading: true,
answers: [],
});

sendChatMessage(
{
roomId: Number(chatId),
message: acceptMessage,
intent: "TAROT_ACCEPT",
},
{
onSuccess: (data) => {
deleteMessage(loadingMessageId);

addMessage({
messageId: data.messageId,
type: data.type,
sender: data.sender,
answers: data.answers,
});
},
}
);
};

const handleRejectClick = async () => {
addMessage({
messageId: Math.random(),
type: "USER_NORMAL",
sender: "USER",
answers: [rejectMessage],
});

await delay(500);

const loadingMessageId = Math.random();

addMessage({
messageId: loadingMessageId,
type: "SYSTEM_NORMAL_REPLY",
sender: "SYSTEM",
loading: true,
answers: [],
});

sendChatMessage(
{
roomId: Number(chatId),
message: rejectMessage,
intent: "TAROT_DECLINE",
},
{
onSuccess: (data) => {
deleteMessage(loadingMessageId);

addMessage({
messageId: data.messageId,
type: data.type,
sender: data.sender,
answers: data.answers,
});
},
}
);
};

if (!open) return null;

return (
<div
css={css`
display: flex;
gap: 8px;
margin-top: 76px;
`}
>
<ChipButton type="button" disabled={isSendingChatMessage} color="primary02" onClick={handleAcceptClick}>
{acceptMessage}
</ChipButton>
<ChipButton type="button" disabled={isSendingChatMessage} color="grey30" onClick={handleRejectClick}>
{rejectMessage}
</ChipButton>
</div>
);
}
17 changes: 17 additions & 0 deletions src/chat/components/Chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use client";

import { ChatMessagesProvider } from "@/chat/hooks/useChatMessagesStore";
import ChatHeader from "./ChatHeader";
import ChatRoom from "./ChatRoom";

export default function Chat() {
// TODO: 채팅 메세지 목록 프리페치 SSR 필요
return (
<>
<ChatHeader />
<ChatMessagesProvider>
<ChatRoom />
</ChatMessagesProvider>
</>
);
}
16 changes: 16 additions & 0 deletions src/chat/components/ChatAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { css } from "styled-components";

export default function ChatAvatar() {
// TODO: 이미지로 교체
return (
<div
css={css`
width: 36px;
height: 36px;
border-radius: 50%;
background-color: ${({ theme }) => theme.colors.grey10};
border: 1px solid ${({ theme }) => theme.colors.grey20};
`}
/>
);
}
105 changes: 105 additions & 0 deletions src/chat/components/ChatBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { MessageSenderType } from "@/chat/models/messageSender";
import { TarotCardIdType } from "@/tarot/models/tarotCardId";
import styled, { css, keyframes, useTheme } from "styled-components";

const fadeInOut = keyframes`
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
`;

type Props = {
sender: MessageSenderType;
message?: string;
card?: TarotCardIdType;
loading?: boolean;
};
// TODO: 말풍선 컴포넌트 리팩터
export default function ChatBubble({ sender, message, card, loading }: Props) {
const theme = useTheme();

if (sender === "USER") {
return (
<div
css={css`
padding: 8px 12px;
background-color: ${({ theme }) => theme.colors.primary01};
${({ theme }) => theme.fonts.body3}
color: ${({ theme }) => theme.colors.grey90};
border-radius: 8px;
max-width: 260px;
margin-left: auto;
white-space: pre-wrap;
`}
>
{message}
</div>
);
}

if (loading) {
return (
<div
css={css`
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
background-color: ${({ theme }) => theme.colors.grey10};
border-radius: 8px;
height: 40px;
padding-inline: 12px;
width: fit-content;
`}
>
<Dot $delay={0} $color={theme.colors.primary01} />
<Dot $delay={0.3} $color={theme.colors.primary02} />
<Dot $delay={0.6} $color={theme.colors.primary03} />
</div>
);
}

if (card) {
return (
<div // TODO: 이미지로 교체
css={css`
background-color: ${({ theme }) => theme.colors.grey50};
border-radius: 8px;
width: 100px;
height: 160px;
`}
/>
);
}

return (
<div
css={css`
padding: 8px 12px;
background-color: ${({ theme }) => theme.colors.grey10};
${({ theme }) => theme.fonts.body3}
border-radius: 8px;
max-width: 260px;
color: ${({ theme }) => theme.colors.grey90};
white-space: pre-wrap;
`}
>
{message}
</div>
);
}

const Dot = styled.span<{ $delay: number; $color: string }>`
width: 6px;
height: 6px;
background-color: ${({ $color }) => $color};
border-radius: 50%;
animation: ${fadeInOut} 1.5s infinite ease-in-out;
animation-delay: ${({ $delay }) => $delay}s;
`;
63 changes: 63 additions & 0 deletions src/chat/components/ChatBubbleGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { MessageType } from "@/chat/models/message";
import { css, styled } from "styled-components";
import ChatAvatar from "./ChatAvatar";
import ChatBubble from "./ChatBubble";

type Props = {
message: MessageType;
isJustSent: boolean;
};

export default function ChatBubbleGroup({ message }: Props) {
// TODO: 응답을 새로 받은 경우에만 메세지를 순차적으로 렌더링
const renderMessage = (message: MessageType) => {
if (message.tarotName) {
return <ChatBubble key={message.messageId} sender={"SYSTEM"} card={message.tarotName} />;
}

if (message.loading) {
return <ChatBubble key={message.messageId} sender={"SYSTEM"} loading />;
}

const addIdToMessages = (messages: string[]) => {
return messages.map((answer) => ({ messageId: Math.random(), sender: "SYSTEM", message: answer }));
};
return addIdToMessages(message.answers).map((answer) => (
<ChatBubble key={answer.messageId} sender={"SYSTEM"} message={answer.message} />
));
};

return (
<div
css={css`
display: flex;
gap: 8px;
`}
>
<ChatAvatar />
<div
css={css`
display: flex;
flex-direction: column;
gap: 4px;
`}
>
<Nickname>타로냥</Nickname>
<div
css={css`
display: flex;
flex-direction: column;
gap: 4px;
`}
>
{renderMessage(message)}
</div>
</div>
</div>
);
}

const Nickname = styled.p`
${({ theme }) => theme.fonts.subHead1}
color: ${({ theme }) => theme.colors.grey90};
`;
Loading

0 comments on commit 652af8d

Please sign in to comment.