Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions src/app/chat/[roomId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';

import { DEFAULT_PROFILE_IMAGE } from 'constants/default-images';

import { ChatHeader, ChatInput, MyChat, OtherChat } from '@/components/pages/chat';

// 임시 데이터
const data = Array.from({ length: 30 }, (_, index) => ({
let data = Array.from({ length: 30 }, (_, index) => ({
id: index + 1,
text: '안녕하세요 멍선생입니다 계속 입력하면 어떻게 되나 봅시다',
text: '안녕하세요 멍선생입니다 \n 계속 입력하면 어떻게 되나 봅시다',
time: '오후 11:24',
profileImage: DEFAULT_PROFILE_IMAGE,
nickName: '흰둥이',
userId: index % 3 === 0 ? 0 : 1,
}));

data = [
...data,
{
id: 31,
text: '어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마어마하게 긴 메세지',
time: '오후 11:25',
profileImage: DEFAULT_PROFILE_IMAGE,
nickName: '흰둥이',
userId: 1,
},
];
const myId = 0;

const ChatRoomPage = () => {
Expand All @@ -33,26 +43,34 @@ const ChatRoomPage = () => {
setMessages((prev) => [...prev, newMessage]);
};

const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);

// 마지막(가장 최근) 메세지 ref로 스크롤 이동
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;

// 마지막 메세지 ref로 스크롤 이동
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
// 다음 프레임에서 스크롤 실행
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
}, [messages]);

return (
<div className='flex h-[calc(100vh-112px)] flex-col'>
<ChatHeader />

<div className='scrollbar-thin ml-4 flex-1 overflow-y-auto pt-4'>
<div
ref={containerRef}
className='scrollbar-thin ml-4 flex flex-1 flex-col gap-4 overflow-y-auto py-4'
>
{messages.map((item) =>
item.userId === myId ? (
<MyChat key={item.id} item={item} />
) : (
<OtherChat key={item.id} item={item} />
),
)}
<div ref={bottomRef} />
</div>

<ChatInput onSubmit={handleSubmit} />
Expand Down
4 changes: 2 additions & 2 deletions src/app/message/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';

import { API } from '@/api';
import { Chat } from '@/components/pages/chat';
import { ChatList } from '@/components/pages/chat';
import { FollowingList, FollowingNone, FollowingSearch } from '@/components/pages/message';
import { TabNavigation } from '@/components/shared';
import { useInfiniteScroll } from '@/hooks/use-group/use-group-infinite-list';
Expand Down Expand Up @@ -66,7 +66,7 @@ export default function FollowingPage() {
<div className='min-h-screen bg-[#F1F5F9]'>
<TabNavigation basePath='/message' defaultValue='chat' tabs={SOCIAL_TABS} />

{tab === 'chat' && <Chat />}
{tab === 'chat' && <ChatList />}
{tab === 'following' && (
<>
<FollowingSearch userId={userId} />
Expand Down
2 changes: 1 addition & 1 deletion src/components/pages/chat/chat-header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Icon } from '@/components/icon';
export const ChatHeader = () => {
const router = useRouter();
return (
<div className='bg-mono-white flex w-full items-center justify-between border-b border-gray-200 px-5 py-3'>
<div className='bg-mono-white sticky flex w-full items-center justify-between border-b border-gray-200 px-5 py-3'>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 top이랑 z-index 지정 안해줘도 잘 고정되나요??
저는 그 두 개 무조건 해야 하는 줄 알았어요.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헛 제가 이것저것 테스트해보다가 못지웠네요.. 수정하겠습니다!🤓

<Icon
id='chevron-left-2'
className='w-6 cursor-pointer text-gray-500'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const dummy = [
},
];

export const Chat = () => {
export const ChatList = () => {
const router = useRouter();
const handleClick = () => {
router.push('/chat/1');
Expand Down
38 changes: 38 additions & 0 deletions src/components/pages/chat/chat-long-text/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client';

import { Icon } from '@/components/icon';
import { useModal } from '@/components/ui';
import { useLongText } from '@/hooks/use-chat/use-chat-longText';

import { LongTextModal } from '../chat-modal';

interface Props {
text: string;
className?: string;
}

export const ExpandableText = ({ text, className }: Props) => {
const { open } = useModal();
const { textRef, isLongText } = useLongText(text);

return (
<>
<span
ref={textRef}
className={`line-clamp-20 block whitespace-pre-line text-gray-800 ${className}`}
>
{text}
</span>

{isLongText && (
<button
className='text-text-xs-medium mt-2 flex items-center'
onClick={() => open(<LongTextModal text={text} />)}
>
<span className='text-gray-500'>전체보기 </span>
<Icon id='chevron-right-1' className='w-4 text-gray-600' />
</button>
)}
</>
);
};
13 changes: 13 additions & 0 deletions src/components/pages/chat/chat-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ModalContent, ModalTitle } from '@/components/ui';

export const LongTextModal = ({ text }: { text: string }) => (
<ModalContent className='mx-4 w-full max-w-[311px]'>
<div className='mb-4'>
<ModalTitle>메세지 전체보기</ModalTitle>
</div>

<div className='scrollbar-thin max-h-[70vh] overflow-y-auto px-1'>
<div className='whitespace-pre-line text-gray-800'>{text}</div>
</div>
</ModalContent>
);
15 changes: 9 additions & 6 deletions src/components/pages/chat/chat-my-chat/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ExpandableText } from '../chat-long-text';

interface IProps {
item: {
id: number;
nickName: string;
profileImage: string;
text: string;
Expand All @@ -10,13 +11,15 @@ interface IProps {

export const MyChat = ({ item }: IProps) => {
const { text, time } = item;

return (
<div className='mr-3 mb-5 flex justify-end'>
<div className='mr-3 flex justify-end'>
<div className='text-text-2xs-regular flex items-end py-1 text-gray-500'>{time}</div>
<div className='ml-1.5 flex flex-col'>
<span className='bg-mint-200 mt-1 max-w-60 rounded-tl-2xl rounded-tr-sm rounded-br-2xl rounded-bl-2xl px-4 py-3 text-gray-800'>
{text}
</span>

<div className='ml-1.5'>
<div className='bg-mint-200 mt-1 max-w-60 rounded-tl-2xl rounded-tr-sm rounded-br-2xl rounded-bl-2xl px-4 py-3'>
<ExpandableText text={text} />
</div>
</div>
</div>
);
Expand Down
17 changes: 11 additions & 6 deletions src/components/pages/chat/chat-other-chat/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Image from 'next/image';

import { ExpandableText } from '../chat-long-text';

interface IProps {
item: {
id: number;
nickName: string;
profileImage: string;
text: string;
Expand All @@ -12,21 +13,25 @@ interface IProps {

export const OtherChat = ({ item }: IProps) => {
const { nickName, profileImage, text, time } = item;

return (
<div className='mb-5 flex'>
<div className='flex'>
<Image
width={40}
className='mr-3 size-10 rounded-full object-cover'
alt='프로필 이미지'
height={40}
src={profileImage}
/>
<div className='mr-1.5 flex flex-col'>

<div className='mr-1.5 max-w-60'>
<span className='text-text-xs-medium text-gray-800'>{nickName}</span>
<span className='bg-mono-white mt-1 max-w-60 rounded-tl-sm rounded-tr-2xl rounded-br-2xl rounded-bl-2xl px-4 py-3 text-gray-800'>
{text}
</span>

<div className='bg-mono-white mt-1 rounded-tl-sm rounded-tr-2xl rounded-br-2xl rounded-bl-2xl px-4 py-3'>
<ExpandableText text={text} />
</div>
</div>

<div className='text-text-2xs-regular flex items-end py-1 text-gray-500'>{time}</div>
</div>
);
Expand Down
4 changes: 3 additions & 1 deletion src/components/pages/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export { Chat } from './chat';
export { ChatHeader } from './chat-header';
export { ChatInput } from './chat-input';
export { ChatList } from './chat-list';
export { ExpandableText } from './chat-long-text';
export { LongTextModal } from './chat-modal';
export { MyChat } from './chat-my-chat';
export { OtherChat } from './chat-other-chat';
32 changes: 32 additions & 0 deletions src/hooks/use-chat/use-chat-longText/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useLayoutEffect, useRef, useState } from 'react';

export const useLongText = (text: string, maxLines = 20) => {
const textRef = useRef<HTMLSpanElement>(null);
const [isLongText, setIsLongText] = useState(false);

// 20줄이 넘어가면 longText로 판단.
useLayoutEffect(() => {
if (!textRef.current) return;

const el = textRef.current;

const originalDisplay = el.style.display;
const originalClamp = el.style.webkitLineClamp;
const originalOverflow = el.style.overflow;

el.style.display = 'block';
el.style.webkitLineClamp = 'unset';
el.style.overflow = 'visible';

const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
const fullHeight = el.scrollHeight;

setIsLongText(fullHeight > lineHeight * maxLines);

el.style.display = originalDisplay;
el.style.webkitLineClamp = originalClamp;
el.style.overflow = originalOverflow;
}, [text, maxLines]);

return { textRef, isLongText };
};
Comment on lines 8 to 40
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

측정을 위해 DOM style을 직접 건드는 방식은 깜빡임과 같은 형상이 있진 않나요?
없다면 괘찮지만 클론을 만들어서 오프스크린에서 측정하는 방식도 있어요.
useLayoutEffect가 paint 전에 실행되기는 하지만 렌더 타이밍이나 컨테이너 width 확정 타이밍 같은 요소 때문에 부작용이 있지 않을까 싶고 실제 엘리먼트 스타일을 바꿨다 복구하면서 측정하는게 위험해 보여서 말씀드려요!

  const el = textRef.current;
  if (!el) return;
  
  ...
  const style = getComputedStyle(el);
  ...

  const clone = el.cloneNode(true) as HTMLSpanElement;
  clone.style.position = 'absolute';
  clone.style.visibility = 'hidden';
  clone.style.pointerEvents = 'none';
  clone.style.height = 'auto';
  ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 지적 감사합니다!
말씀하신대로 DOM을 직접 조작하는건 위험한 것 같아요
오프스크린 적용해서 수정하도록 하겠습니다!!😀

7 changes: 7 additions & 0 deletions src/styles/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,10 @@
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply rounded-lg bg-gray-400;
}

.line-clamp-20 {
display: -webkit-box;
-webkit-line-clamp: 20;
-webkit-box-orient: vertical;
overflow: hidden;
}