Skip to content

Commit

Permalink
Feature/#339 - 채팅 좋아요순 정렬 적용, 채팅 줄바꿈/엔터 전송 적용 (#348)
Browse files Browse the repository at this point in the history
* ✨ feat: 채팅 정렬 상태 추가

* ✨ feat: useInfiniteQuery 옵션에서 pageParam을 설정하도록 변경

* ✨ feat: 채팅의 작성자인지 확인하는 유틸함수 분리

* ✨ feat: 채팅 정렬 상태 관리하는 커스텀훅 구현

* ✨ feat: 채팅 좋아요 순 쿼리 반영

* ✨ feat: 좋아요순으로 불러올 때 받아온 채팅 데이터를 저장하도록 설정

* 🐛 fix: 테마 저장 로컬스토리지 변경

* 🐛 fix: 테마 localStorage key값 원래대로 변경

* 🐛 fix: 시가총액에 억 단위 추가

* ✨ feat: 채팅 엔터키로 전송, 줄바꿈 반영

* 🐛 fix: 채팅의 개행이 필요할 때 개행되지 않는 문제 수정

* 🐛 fix: isFetching 상태일 때 로더를 띄우도록 설정

* ✨ feat: hasNextPage를 추가하여 페이지가 더이상 없을 때 리렌더링 방지

* 🐛 fix: flat 메서드 삭제
  • Loading branch information
baegyeong authored Dec 4, 2024
1 parent 5455bad commit 01f1c5d
Show file tree
Hide file tree
Showing 10 changed files with 125 additions and 67 deletions.
1 change: 1 addition & 0 deletions packages/frontend/src/apis/queries/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './schema';
export * from './usePostChatLike';
export * from './useGetChatList';
1 change: 1 addition & 0 deletions packages/frontend/src/apis/queries/chat/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const GetChatListRequestSchema = z.object({
stockId: z.string(),
latestChatId: z.number().optional(),
pageSize: z.number().optional(),
order: z.enum(['latest', 'like']).optional(),
});

export type GetChatListRequest = z.infer<typeof GetChatListRequestSchema>;
35 changes: 28 additions & 7 deletions packages/frontend/src/apis/queries/chat/useGetChatList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,49 @@ import { GetChatListRequest } from './schema';
import { get } from '@/apis/utils/get';
import { ChatDataResponse, ChatDataResponseSchema } from '@/sockets/schema';

const getChatList = ({ stockId, latestChatId, pageSize }: GetChatListRequest) =>
const getChatList = ({
stockId,
latestChatId,
pageSize,
order,
}: GetChatListRequest) =>
get<ChatDataResponse>({
schema: ChatDataResponseSchema,
url: '/api/chat',
params: {
stockId,
latestChatId,
pageSize,
order,
},
});

export const useGetChatList = ({
stockId,
latestChatId,
pageSize,
order,
}: GetChatListRequest) => {
return useInfiniteQuery({
queryKey: ['chatList', stockId, latestChatId, pageSize],
queryFn: () => getChatList({ stockId, latestChatId, pageSize }),
getNextPageParam: (data) => (data.hasMore ? true : null),
initialPageParam: false,
staleTime: 60 * 1000 * 5,
enabled: !!latestChatId,
queryKey: ['chatList', stockId, order],
queryFn: ({ pageParam }) =>
getChatList({
stockId,
latestChatId: pageParam?.latestChatId,
pageSize,
order,
}),
getNextPageParam: (lastPage) =>
lastPage.hasMore
? {
latestChatId: lastPage.chats[lastPage.chats.length - 1].id,
}
: undefined,
initialPageParam: { latestChatId },
select: (data) => ({
pages: [...data.pages].flatMap((page) => page.chats),
pageParams: [...data.pageParams],
}),
staleTime: 1000 * 60 * 5,
});
};
2 changes: 1 addition & 1 deletion packages/frontend/src/constants/metricDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const METRICS_DATA = ({
metrics: [
{
...METRIC_DETAILS.enterpriseValue.marketCap,
value: `${marketCap?.toLocaleString()}`,
value: `${marketCap?.toLocaleString()}억원`,
},
{
...METRIC_DETAILS.enterpriseValue.per,
Expand Down
12 changes: 6 additions & 6 deletions packages/frontend/src/hooks/useInfiniteScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@ import { useEffect, useRef } from 'react';

interface InfiniteScrollProps {
onIntersect: () => void;
hasMore: boolean;
hasNextPage: boolean;
}

export const useInfiniteScroll = ({
onIntersect,
hasMore,
hasNextPage,
}: InfiniteScrollProps) => {
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore) {
if (entries[0].isIntersecting && hasNextPage) {
onIntersect();
}
},
{ threshold: 0.3 },
{ threshold: 0.5 },
);
const instance = ref.current;

Expand All @@ -28,10 +28,10 @@ export const useInfiniteScroll = ({

return () => {
if (instance) {
observer.unobserve(instance);
observer.disconnect();
}
};
}, [onIntersect, hasMore]);
}, [onIntersect, hasNextPage]);

return { ref };
};
101 changes: 51 additions & 50 deletions packages/frontend/src/pages/stock-detail/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { TextArea } from './components';
import { ChatMessage } from './components/ChatMessage';
import { useChatOrder } from './hooks/useChatOrder';
import { GetLoginStatus } from '@/apis/queries/auth/schema';
import { usePostChatLike } from '@/apis/queries/chat';
import { useGetChatList } from '@/apis/queries/chat/useGetChatList';
Expand All @@ -15,32 +16,33 @@ import {
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { socketChat } from '@/sockets/config';
import {
ChatDataResponseSchema,
ChatDataSchema,
ChatLikeSchema,
type ChatLikeResponse,
type ChatData,
type ChatDataResponse,
} from '@/sockets/schema';
import { useWebsocket } from '@/sockets/useWebsocket';
import { checkChatWriter } from '@/utils/checkChatWriter';
import { cn } from '@/utils/cn';

interface ChatPanelProps {
loginStatus: GetLoginStatus;
isOwnerStock: boolean;
}

const INITIAL_VISIBLE_CHATS = 3;

export const ChatPanel = ({ loginStatus, isOwnerStock }: ChatPanelProps) => {
const { message, nickname, subName } = loginStatus;

const { stockId = '' } = useParams();
const [chatData, setChatData] = useState<ChatData[]>([]);
const [latestChatId, setLatestChatId] = useState<number>();
const [hasMore, setHasMore] = useState(false);

const { mutate } = usePostChatLike();
const { message, nickname, subName } = loginStatus;
const { mutate: clickLike } = usePostChatLike();
const { order, handleOrderType } = useChatOrder();

const socket = useMemo(() => socketChat({ stockId }), [stockId]);

const { isConnected } = useWebsocket(socket);

const userStatus: ChatStatus = useMemo(() => {
Expand All @@ -51,27 +53,14 @@ export const ChatPanel = ({ loginStatus, isOwnerStock }: ChatPanelProps) => {
return isOwnerStock ? UserStatus.OWNERSHIP : UserStatus.NOT_OWNERSHIP;
}, [message, isOwnerStock]);

const handleChat = useCallback(
(message: ChatDataResponse | Partial<ChatData>) => {
if ('chats' in message && message.chats) {
const validatedChatData = ChatDataResponseSchema.parse(message);
if (validatedChatData) {
setChatData((prev) => [...message.chats, ...prev]);
setHasMore(message.hasMore);
}
return;
}

const validatedSingleChat = ChatDataSchema.partial().parse(message);
if (validatedSingleChat) {
setChatData((prev) => [
{ ...validatedSingleChat } as ChatData,
...prev,
]);
}
},
[],
);
const handleChat = useCallback((message: ChatDataResponse | ChatData) => {
if ('chats' in message) return;

const validatedSingleChat = ChatDataSchema.parse(message);
if (validatedSingleChat) {
setChatData((prev) => [{ ...validatedSingleChat } as ChatData, ...prev]);
}
}, []);

const handleSendMessage = (message: string) => {
if (!message) return;
Expand Down Expand Up @@ -107,35 +96,42 @@ export const ChatPanel = ({ loginStatus, isOwnerStock }: ChatPanelProps) => {
}, [stockId, socket, handleChat]);

const handleLikeClick = (chatId: number) => {
if (isOwnerStock) {
mutate({ chatId });
return;
}
alert('주식 소유자만 가능합니다.');
if (!isOwnerStock) return alert('주식 소유자만 가능합니다.');
clickLike({ chatId });
};

const { fetchNextPage, data, status, isFetchingNextPage } = useGetChatList({
stockId,
latestChatId,
});
const { fetchNextPage, data, status, isFetching, hasNextPage } =
useGetChatList({
stockId,
order,
});

useEffect(() => {
if (status === 'success') {
setChatData(data.pages);
}
}, [data, status]);

const fetchMoreChats = () => {
setLatestChatId(chatData[chatData.length - 1]?.id);
fetchNextPage();

if (status === 'success') {
setChatData((prev) => [...prev, ...data.pages[0].chats]);
setHasMore(data.pages[0].hasMore);
setChatData(data.pages);
}
};

const { ref } = useInfiniteScroll({
onIntersect: fetchMoreChats,
hasMore,
hasNextPage,
});

const checkWriter = (chat: ChatData) =>
chat.nickname === nickname && chat.subName === subName;
const isWriter = (chat: ChatData) => {
if (!nickname || !subName) {
return false;
}

return checkChatWriter({ chat, nickname, subName });
};

return (
<article className="flex min-w-80 flex-col gap-5 rounded-md bg-white p-7 shadow">
Expand All @@ -147,9 +143,14 @@ export const ChatPanel = ({ loginStatus, isOwnerStock }: ChatPanelProps) => {
/>
<div className="border-light-gray display-medium12 text-dark-gray flex items-center justify-between gap-1 border-b-2 pb-2">
<span>{isConnected ? '🟢 접속 중' : '❌ 연결 끊김'}</span>
<div className="flex items-center gap-2">
<p>최신순</p>
<DownArrow className="cursor-pointer" />
<div className="flex items-center gap-2" onClick={handleOrderType}>
<p>{order === 'latest' ? '최신순' : '좋아요순'}</p>
<DownArrow
className={cn(
'cursor-pointer',
order === 'latest' ? 'rotate-0' : 'rotate-180',
)}
/>
</div>
</div>
<section
Expand All @@ -160,7 +161,7 @@ export const ChatPanel = ({ loginStatus, isOwnerStock }: ChatPanelProps) => {
>
{chatData.length ? (
<>
{chatData.slice(0, 3).map((chat) => (
{chatData.slice(0, INITIAL_VISIBLE_CHATS).map((chat) => (
<ChatMessage
key={chat.id}
name={chat.nickname}
Expand All @@ -169,11 +170,11 @@ export const ChatPanel = ({ loginStatus, isOwnerStock }: ChatPanelProps) => {
liked={chat.liked}
subName={chat.subName}
createdAt={chat.createdAt}
writer={checkWriter(chat)}
writer={isWriter(chat)}
onClick={() => handleLikeClick(chat.id)}
/>
))}
{chatData.slice(3).map((chat, index) => (
{chatData.slice(INITIAL_VISIBLE_CHATS).map((chat, index) => (
<div className="relative" key={`${chat.id}-${index}`}>
{!isOwnerStock && (
<div className="absolute inset-0 flex items-center justify-center text-center backdrop-blur-sm">
Expand All @@ -192,7 +193,7 @@ export const ChatPanel = ({ loginStatus, isOwnerStock }: ChatPanelProps) => {
liked={chat.liked}
subName={chat.subName}
createdAt={chat.createdAt}
writer={checkWriter(chat)}
writer={isWriter(chat)}
onClick={() => handleLikeClick(chat.id)}
/>
</div>
Expand All @@ -201,7 +202,7 @@ export const ChatPanel = ({ loginStatus, isOwnerStock }: ChatPanelProps) => {
) : (
<p className="text-center">채팅이 없어요.</p>
)}
{isFetchingNextPage ? <Loader className="w-44" /> : <div ref={ref} />}
{isFetching ? <Loader className="w-44" /> : <div ref={ref} />}
</section>
</article>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const ChatMessage = ({
<div className="flex items-center gap-2">
<p
className={cn(
'display-bold14 w-full rounded p-2 py-3',
'display-bold14 w-full whitespace-pre-wrap rounded p-2 py-3',
writer ? 'bg-light-yellow' : 'bg-extra-light-gray',
)}
>
Expand Down
13 changes: 11 additions & 2 deletions packages/frontend/src/pages/stock-detail/components/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FormEvent, useState } from 'react';
import { type FormEvent, type KeyboardEvent, useState } from 'react';
import Send from '@/assets/send.svg?react';
import { cn } from '@/utils/cn';

Expand All @@ -12,13 +12,21 @@ export const TextArea = ({ disabled, onSend, placeholder }: TextAreaProps) => {
const [chatText, setChatText] = useState('');
const [inputCount, setInputCount] = useState(0);

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
const handleSubmit = (event: FormEvent | KeyboardEvent) => {
event.preventDefault();
if (chatText.trim() === '') return;
onSend(chatText);
setChatText('');
setInputCount(0);
};

const handleEnterPress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit(event);
}
};

return (
<div className="flex flex-col">
<form className="relative w-full" onSubmit={handleSubmit}>
Expand All @@ -36,6 +44,7 @@ export const TextArea = ({ disabled, onSend, placeholder }: TextAreaProps) => {
setChatText(e.target.value);
setInputCount(e.target.value.length);
}}
onKeyDown={handleEnterPress}
/>
<button
className={cn(
Expand Down
11 changes: 11 additions & 0 deletions packages/frontend/src/pages/stock-detail/hooks/useChatOrder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useState } from 'react';

type OrderType = 'latest' | 'like';

export const useChatOrder = () => {
const [order, setOrder] = useState<OrderType>('latest');
const handleOrderType = () =>
setOrder((prev) => (prev === 'latest' ? 'like' : 'latest'));

return { order, handleOrderType };
};
14 changes: 14 additions & 0 deletions packages/frontend/src/utils/checkChatWriter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ChatData } from '@/sockets/schema';

interface CheckChatWriterProps {
chat: ChatData;
nickname: string;
subName: string;
}

export const checkChatWriter = ({
chat,
nickname,
subName,
}: CheckChatWriterProps) =>
chat.nickname === nickname && chat.subName === subName;

0 comments on commit 01f1c5d

Please sign in to comment.