diff --git a/src/frontend/apps/web/src/features/chat/model/index.ts b/src/frontend/apps/web/src/features/chat/model/index.ts index d092fdb9..ab26dd7b 100644 --- a/src/frontend/apps/web/src/features/chat/model/index.ts +++ b/src/frontend/apps/web/src/features/chat/model/index.ts @@ -6,3 +6,4 @@ export type { } from './websocket.type'; export { useMessages } from './use-messages'; export { useWebSocketClient } from './use-websocket-client'; +export { useChatAutoScroll } from './use-chat-autoscroll'; diff --git a/src/frontend/apps/web/src/features/chat/model/use-chat-autoscroll.ts b/src/frontend/apps/web/src/features/chat/model/use-chat-autoscroll.ts new file mode 100644 index 00000000..a4907b85 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/model/use-chat-autoscroll.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react'; +import { WebSocketResponsePayload } from './websocket.type'; +import { useToast } from '@workspace/ui/hooks/Toast/use-toast'; + +export const useChatAutoScroll = (messages: WebSocketResponsePayload[]) => { + const bottomRef = useRef(null); + const containerRef = useRef(null); + const prevMessageCountRef = useRef(0); + const [isUserScrollingUp, setIsUserScrollingUp] = useState(false); + const [newMessageCount, setNewMessageCount] = useState(0); + const { toast, dismiss } = useToast(); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + const isAtBottom = scrollHeight - scrollTop <= clientHeight + 100; + + setIsUserScrollingUp(!isAtBottom); + if (isAtBottom && newMessageCount > 0) { + setNewMessageCount(0); + dismiss('new-message'); + } + }; + + container.addEventListener('scroll', handleScroll); + + if (messages.length > prevMessageCountRef.current) { + if (!isUserScrollingUp) { + requestAnimationFrame(() => { + bottomRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + }); + } else { + setNewMessageCount((prev) => prev + 1); + toast({ + title: '새 메시지가 있습니다', + description: `${newMessageCount + 1}개의 새 메시지가 도착했습니다.`, + }); + } + prevMessageCountRef.current = messages.length; + } + + return () => container.removeEventListener('scroll', handleScroll); + }, [messages, isUserScrollingUp, newMessageCount, toast, dismiss]); + + return { bottomRef, containerRef }; +}; diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-content.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-content.tsx index a1b3b4a2..e73e2e11 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-content.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-content.tsx @@ -1,9 +1,9 @@ import ContentText from './content-text'; import ContentAvatar from './content-avatar'; -import { MessageSquareText } from 'lucide-react'; -import type { WebSocketResponsePayload } from '../model'; +import { useChatAutoScroll, type WebSocketResponsePayload } from '../model'; import { processMessages } from '../lib'; +import { Toaster } from '@workspace/ui/components'; export type ChatContentProps = { type?: 'default' | 'live'; @@ -21,6 +21,8 @@ const ChatContent = ({ avatarUrls, setIsThreadOpen, }: ChatContentWithAvatarsProps) => { + const { bottomRef, containerRef } = useChatAutoScroll(messages); + if (!messages || messages.length === 0) return null; console.log('🔗 ChatContent:', { messages }); @@ -32,6 +34,7 @@ const ChatContent = ({ return ( <>
{processedMessages.map((messageData, index) => ( @@ -61,7 +64,9 @@ const ChatContent = ({
))} +
+ ); }; diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-section-content.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-section-content.tsx index 40684ae5..4b764a75 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-section-content.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-section-content.tsx @@ -47,14 +47,14 @@ const ChatSectionContent = () => { return ( <> -
-
+
+
-
+
diff --git a/src/frontend/apps/web/src/features/chat/ui/header.tsx b/src/frontend/apps/web/src/features/chat/ui/header.tsx index b404446a..7b0cfd91 100644 --- a/src/frontend/apps/web/src/features/chat/ui/header.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/header.tsx @@ -1,6 +1,6 @@ const Header = ({ children }: { children: React.ReactNode }) => { return ( -
+
{children}
); diff --git a/src/frontend/packages/ui/src/components/Toast/index.ts b/src/frontend/packages/ui/src/components/Toast/index.ts index e568026c..cd6f71f5 100644 --- a/src/frontend/packages/ui/src/components/Toast/index.ts +++ b/src/frontend/packages/ui/src/components/Toast/index.ts @@ -1 +1,12 @@ -export { type ToastProps, type ToastActionElement, ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction } from './toast'; +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} from './toast'; +export { Toaster } from './toaster';