From 39d325758adac5cd3e3c45594918c542316a6402 Mon Sep 17 00:00:00 2001 From: KangShingyu <103213494+KangShinGyu98@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:49:39 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(chat):=EC=B1=84=ED=8C=85=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=EC=99=80=20=ED=95=98=EB=8B=A8=EC=97=90=20---?= =?UTF-8?q?"=EC=97=AC=EA=B8=B0=EA=B9=8C=EC=A7=80=20=EC=9D=BD=EC=97=88?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4."---=20=EC=9E=91=EB=8F=99=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95=20(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat):새 메시지가 없는데 --- 표시 제거 * feat(chat):하단에 프리뷰 작성 * fix(chat):우하단 아래로 내려가기 버튼 약간 왼쪽으로 수정 * fix(chat): 프리뷰 디자인 수정 --------- Co-authored-by: “KangShinGyu” <“rkdtlseb@naver.com”> --- .../chat/messages/ChatMemberAvatarRing.tsx | 2 + .../chat/messages/ChatMessageList.tsx | 64 +++++++++++++++-- .../chat/messages/JumpToBottomButton.tsx | 8 ++- .../chat/messages/NewMessagePreviewButton.tsx | 68 +++++++++++++++++++ src/hooks/useChatMessages.ts | 2 + src/lib/chat/readBoundary.test.ts | 41 +++++++++++ src/lib/chat/readBoundary.ts | 10 ++- 7 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 src/components/chat/messages/NewMessagePreviewButton.tsx create mode 100644 src/lib/chat/readBoundary.test.ts diff --git a/src/components/chat/messages/ChatMemberAvatarRing.tsx b/src/components/chat/messages/ChatMemberAvatarRing.tsx index 75de9447..3cd76a01 100644 --- a/src/components/chat/messages/ChatMemberAvatarRing.tsx +++ b/src/components/chat/messages/ChatMemberAvatarRing.tsx @@ -37,6 +37,8 @@ export function ChatMemberAvatarRing({ {alt}, @@ -106,12 +116,18 @@ export function ChatMessageList({ const [isAtBottom, setIsAtBottom] = useState(true); /** 하단으로 부드럽게 이동 중 상태 정리용 */ const [smoothJumpToBottom, setSmoothJumpToBottom] = useState(false); + /** 위로 스크롤한 동안 도착한 다른 멤버의 최신 메시지 */ + const [newMessagePreview, setNewMessagePreview] = + useState(null); + /** 스크롤바 폭(px) — 하단 버튼이 스크롤바에 가리지 않게 그만큼 오른쪽을 비운다 */ + const [scrollbarWidth, setScrollbarWidth] = useState(0); const scrollToBottomSmooth = useCallback(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, []); const handleJumpClick = useCallback(() => { + setNewMessagePreview(null); restoringReadBoundaryRef.current = false; followTailRef.current = true; if (hasMoreNewer && onJumpToLatest) { @@ -150,7 +166,10 @@ export function ChatMessageList({ const near = dist < CHAT_NEAR_BOTTOM_PX; followTailRef.current = near; setIsAtBottomIfChanged(setIsAtBottom, near); - if (near) onAtBottom?.(); + if (near) { + setNewMessagePreview(null); + onAtBottom?.(); + } }, [onAtBottom]); useLayoutEffect(() => { @@ -225,6 +244,22 @@ export function ChatMessageList({ const nearAfter = distAfter < CHAT_NEAR_BOTTOM_PX; setIsAtBottomIfChanged(setIsAtBottom, nearAfter); + const newest = messages[count - 1]; + if ( + appended && + !nearAfter && + newest && + isPreviewMessage(newest) + ) { + // 스크롤 보정이 끝난 다음 프레임에 표시 — 그 사이 하단에 닿았으면 띄우지 않는다 + requestAnimationFrame(() => { + const r = scrollRootRef.current; + if (!r) return; + const d = r.scrollHeight - r.scrollTop - r.clientHeight; + if (d >= CHAT_NEAR_BOTTOM_PX) setNewMessagePreview(newest); + }); + } + if (scrollToAnchor && lastScrollAnchorKeyRef.current !== scrollToAnchor.key) { lastScrollAnchorKeyRef.current = scrollToAnchor.key; const targetId = scrollToAnchor.anchorId; @@ -281,7 +316,13 @@ export function ChatMessageList({ setIsAtBottomIfChanged(setIsAtBottom, d < CHAT_NEAR_BOTTOM_PX); }); } - }, [messages, scrollToAnchor, groups, readMarkerMessageId, readDividerPlacement]); + }, [ + messages, + scrollToAnchor, + groups, + readMarkerMessageId, + readDividerPlacement, + ]); useEffect(() => { const root = scrollRootRef.current; @@ -370,6 +411,9 @@ export function ChatMessageList({ const lastClientHeightRef = { current: root.clientHeight }; const ro = new ResizeObserver(() => { + const sbw = root.offsetWidth - root.clientWidth; + setScrollbarWidth((prev) => (prev === sbw ? prev : sbw)); + const ch = root.clientHeight; if (ch === lastClientHeightRef.current) return; lastClientHeightRef.current = ch; @@ -493,8 +537,20 @@ export function ChatMessageList({
- {!isAtBottom ? ( - + {!isAtBottom && newMessagePreview ? ( + + ) : null} + {!isAtBottom && !newMessagePreview ? ( + ) : null} ); diff --git a/src/components/chat/messages/JumpToBottomButton.tsx b/src/components/chat/messages/JumpToBottomButton.tsx index ebade42b..5f525e5c 100644 --- a/src/components/chat/messages/JumpToBottomButton.tsx +++ b/src/components/chat/messages/JumpToBottomButton.tsx @@ -6,12 +6,18 @@ import { cn } from "@/lib/utils"; export function JumpToBottomButton({ onClick, isMinimized = false, + scrollbarWidth = 0, }: { onClick: () => void; isMinimized?: boolean; + /** 메시지 목록 스크롤바 폭 — 버튼을 스크롤바 바깥(왼쪽)에 둔다 */ + scrollbarWidth?: number; }) { return ( -
+
+
+ ); +} diff --git a/src/hooks/useChatMessages.ts b/src/hooks/useChatMessages.ts index 6f40afa5..976e3089 100644 --- a/src/hooks/useChatMessages.ts +++ b/src/hooks/useChatMessages.ts @@ -339,6 +339,7 @@ export function useChatMessages( bridged.slice, lastReadMessageId, bridged.placementBeforeSlice, + hasMoreNewerByRoomRef.current.get(targetRoomId) === true, ); applySessionReadBoundary(rid, lastReadMessageId, placement); @@ -462,6 +463,7 @@ export function useChatMessages( slice, out.lastReadMessageId, placementBeforeSlice, + hasMoreNewer, ) : EMPTY_READ_DIVIDER_PLACEMENT; applySessionReadBoundary(ridTrim, out.lastReadMessageId, placement); diff --git a/src/lib/chat/readBoundary.test.ts b/src/lib/chat/readBoundary.test.ts new file mode 100644 index 00000000..5191cd1f --- /dev/null +++ b/src/lib/chat/readBoundary.test.ts @@ -0,0 +1,41 @@ +import { expect, it, vi } from "vitest"; +import type { ServerChatMessage } from "@/types/chat"; +import { + EMPTY_READ_DIVIDER_PLACEMENT, + resolveReadDividerPlacement, +} from "./readBoundary"; + +vi.mock("@/lib/api/rooms", () => ({ getRoomMessages: vi.fn() })); + +function msg(id: string, minute: number): ServerChatMessage { + return { + id, + roomId: "room", + senderId: 1, + messageType: "CHAT", + content: id, + createdAt: `2026-10-01T00:${String(minute).padStart(2, "0")}:00Z`, + }; +} + +const slice = [msg("a", 0), msg("b", 1)]; + +it("does not draw the read divider when nothing comes after the last read message", () => { + expect(resolveReadDividerPlacement(slice, "b", slice)).toEqual( + EMPTY_READ_DIVIDER_PLACEMENT, + ); +}); + +it("draws the read divider before unread messages", () => { + expect(resolveReadDividerPlacement(slice, "a", slice)).toEqual({ + afterMessageId: "a", + beforeFirst: false, + }); +}); + +it("keeps the divider at the loaded tail when newer pages remain", () => { + expect(resolveReadDividerPlacement(slice, "b", slice, true)).toEqual({ + afterMessageId: "b", + beforeFirst: false, + }); +}); diff --git a/src/lib/chat/readBoundary.ts b/src/lib/chat/readBoundary.ts index 215c87cf..333f6ac7 100644 --- a/src/lib/chat/readBoundary.ts +++ b/src/lib/chat/readBoundary.ts @@ -31,16 +31,22 @@ export function resolveReadDividerPlacement( slice: readonly ServerChatMessage[], lastReadMessageId: string | null, beforeSlice: readonly ServerChatMessage[], + hasMoreNewer = false, ): ReadDividerPlacement { if (!lastReadMessageId) { return EMPTY_READ_DIVIDER_PLACEMENT; } + // 경계 뒤에 새 메시지가 없으면 목록 맨 아래에 구분선만 남으므로 그리지 않는다 + const placeAfter = (messageId: string): ReadDividerPlacement => + !hasMoreNewer && newestServerMessageByCreatedAt([...slice])?.id === messageId + ? EMPTY_READ_DIVIDER_PLACEMENT + : { afterMessageId: messageId, beforeFirst: false }; if (slice.some((m) => m.id === lastReadMessageId)) { - return { afterMessageId: lastReadMessageId, beforeFirst: false }; + return placeAfter(lastReadMessageId); } const beforeNewest = newestServerMessageByCreatedAt([...beforeSlice]); if (beforeNewest) { - return { afterMessageId: beforeNewest.id, beforeFirst: false }; + return placeAfter(beforeNewest.id); } if (slice.length > 0) { return { afterMessageId: null, beforeFirst: true }; From 529a1ae08f197c218b977e429ac9e814f219e968 Mon Sep 17 00:00:00 2001 From: KangShingyu <103213494+KangShinGyu98@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:49:57 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix(bottom-sheet):=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20=EC=A2=8C=EC=9A=B0=20?= =?UTF-8?q?=EC=A7=A4=EB=A6=AC=EB=8D=98=EA=B1=B0=20=EC=88=98=EC=A0=95=20(#2?= =?UTF-8?q?06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: “KangShinGyu” <“rkdtlseb@naver.com”> --- .../_components/mobile/MobileMemoSheet.tsx | 26 ++++++++++--------- src/components/mobile/MobileBottomSheet.tsx | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx b/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx index 5c9ffb2c..d6bf4add 100644 --- a/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx +++ b/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx @@ -62,16 +62,18 @@ export function MobileMemoSheet({ >