From b1eadf21641fe04c820ae65fc62b5f3053368e2d Mon Sep 17 00:00:00 2001 From: Dohyeon Date: Wed, 19 Aug 2026 13:27:55 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=ED=83=AD?= =?UTF-8?q?=C2=B7=EA=B3=B5=EC=9A=A9=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=C2=B7?= =?UTF-8?q?=EB=AF=B8=EC=9D=BD=EC=9D=8C=20=EB=B1=83=EC=A7=80=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USER·MANAGER 공용 /chat 경로와 Docbar 채팅 탭을 추가하고, 지원자 탭을 채팅으로 교체한다. 미읽음 합계는 UnreadBadge로 표시한다. Co-authored-by: Cursor --- src/assets/icons/doc/Chat.svg | 3 + src/shared/constants/routes.ts | 13 ++++ src/shared/lib/queryKeys.ts | 20 +++++++ src/shared/lib/unreadCount.ts | 8 +++ src/shared/stores/useChatUnreadStore.ts | 36 +++++++++++ src/shared/stores/useDocStore.ts | 3 + src/shared/types/tab.ts | 11 +++- src/shared/ui/common/Docbar.tsx | 48 +++++++++++---- src/shared/ui/common/UnreadBadge.tsx | 36 +++++++++++ storybook/stories/Docbar.stories.tsx | 80 ++++++++++++++----------- 10 files changed, 211 insertions(+), 47 deletions(-) create mode 100644 src/assets/icons/doc/Chat.svg create mode 100644 src/shared/lib/unreadCount.ts create mode 100644 src/shared/stores/useChatUnreadStore.ts create mode 100644 src/shared/ui/common/UnreadBadge.tsx diff --git a/src/assets/icons/doc/Chat.svg b/src/assets/icons/doc/Chat.svg new file mode 100644 index 0000000..15e3793 --- /dev/null +++ b/src/assets/icons/doc/Chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index 98bfa26..ba205e1 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -35,7 +35,9 @@ export const ROUTES = { STORE_REGISTER: '/manager/store-register', SUBSTITUTE_REQUEST: '/manager/substitute-request', WORKER_INVITE: '/manager/worker-invite', + /** @deprecated 공용 채팅으로 통합 — ROUTES.CHAT.ROOMS 사용 */ SOCIAL: '/manager/social', + /** @deprecated 공용 채팅으로 통합 — ROUTES.CHAT.ROOM_PATTERN 사용 */ SOCIAL_CHAT: '/manager/social/chat', /** 구인구직 — 내 공고 목록 (사장님 알바찾기 탭 진입점) */ POSTINGS: '/manager/postings', @@ -71,6 +73,13 @@ export const ROUTES = { /** 신청 상세 (파라미터) */ REQUEST_DETAIL_PATTERN: '/store-register/requests/:requestId', }, + /** 채팅 — USER·MANAGER 공용. 스코프 차이는 API 경로(app|manager)로만 구분합니다 */ + CHAT: { + /** 채팅 목록 — Docbar 탭 진입점(개인/전체 세그먼트) */ + ROOMS: '/chat', + /** 채팅방 (파라미터) */ + ROOM_PATTERN: '/chat/rooms/:roomId', + }, NOTIFICATIONS: '/notifications', NOTIFICATION_SETTINGS: '/notifications/settings', } as const @@ -108,3 +117,7 @@ export function managerPostingApplicationsPath(postingId?: number) { export function managerPostingApplicationDetailPath(applicationId: number) { return `/manager/postings/applications/${applicationId}` } + +export function chatRoomPath(roomId: number) { + return `/chat/rooms/${roomId}` +} diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 4f90917..6db60c5 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -114,6 +114,26 @@ export const queryKeys = { comments: (scope: 'MANAGER' | 'USER' | null, requestId: number) => ['storeRegisterRequest', 'comments', scope, requestId] as const, }, + chat: { + all: ['chat'] as const, + /** 목록 전체 무효화용 prefix — 세그먼트·pageSize 무관하게 매칭 */ + roomsAll: ['chat', 'rooms'] as const, + rooms: ( + scope: 'MANAGER' | 'USER' | null, + params?: { chatScope?: string; pageSize?: number } + ) => ['chat', 'rooms', scope, params] as const, + /** 딥링크 진입 시 목록 캐시 대신 쓰는 방 상세 */ + roomDetail: (scope: 'MANAGER' | 'USER' | null, roomId: number) => + ['chat', 'roomDetail', scope, roomId] as const, + messages: ( + scope: 'MANAGER' | 'USER' | null, + roomId: number, + params?: { pageSize?: number } + ) => ['chat', 'messages', scope, roomId, params] as const, + /** 새 채팅 상대 후보 — 근무지 동료·점주 */ + contacts: (scope: 'MANAGER' | 'USER' | null) => + ['chat', 'contacts', scope] as const, + }, notification: { list: (scope: 'MANAGER' | 'USER' | null, type?: string) => ['notifications', scope, type] as const, diff --git a/src/shared/lib/unreadCount.ts b/src/shared/lib/unreadCount.ts new file mode 100644 index 0000000..25c3bdf --- /dev/null +++ b/src/shared/lib/unreadCount.ts @@ -0,0 +1,8 @@ +/** 99를 넘는 미읽음은 '99+'로 축약합니다 */ +export const MAX_VISIBLE_UNREAD_COUNT = 99 + +export function formatUnreadCount(count: number): string { + return count > MAX_VISIBLE_UNREAD_COUNT + ? `${MAX_VISIBLE_UNREAD_COUNT}+` + : String(count) +} diff --git a/src/shared/stores/useChatUnreadStore.ts b/src/shared/stores/useChatUnreadStore.ts new file mode 100644 index 0000000..0c3e0d5 --- /dev/null +++ b/src/shared/stores/useChatUnreadStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand' + +/** + * Docbar 채팅 탭 뱃지용 미읽음 합계. + * 채팅 목록 쿼리·STOMP 수신이 스코프별 합계를 기록하고, Docbar는 총합만 읽습니다. + * (shared/ui 인 Docbar 가 features 를 직접 참조하지 않도록 스토어로 분리) + */ +export type ChatUnreadScope = 'personal' | 'group' + +interface ChatUnreadState { + countByScope: Record + setUnreadCount: (scope: ChatUnreadScope, count: number) => void + reset: () => void +} + +export const useChatUnreadStore = create(set => ({ + countByScope: { personal: 0, group: 0 }, + setUnreadCount: (scope, count) => + set(state => + state.countByScope[scope] === count + ? state + : { + countByScope: { + ...state.countByScope, + [scope]: Math.max(0, count), + }, + } + ), + reset: () => set({ countByScope: { personal: 0, group: 0 } }), +})) + +export function selectTotalChatUnread(state: ChatUnreadState): number { + return state.countByScope.personal + state.countByScope.group +} + +export default useChatUnreadStore diff --git a/src/shared/stores/useDocStore.ts b/src/shared/stores/useDocStore.ts index a38ea0f..ec1b266 100644 --- a/src/shared/stores/useDocStore.ts +++ b/src/shared/stores/useDocStore.ts @@ -2,6 +2,8 @@ import { create } from 'zustand' import type { TabKey } from '@/shared/types/tab' const PATHNAME_TAB_MAP: Array<{ matcher: RegExp; tab: TabKey }> = [ + // 채팅은 USER·MANAGER 공용 경로라 스코프별 패턴보다 먼저 매칭합니다 + { matcher: /^\/chat(\/|$)/, tab: 'chat' }, { matcher: /(^|\/)home(\/|$)/, tab: 'home' }, { matcher: /(^|\/)my(\/|$)/, tab: 'my' }, { matcher: /(^|\/)search(\/|$)/, tab: 'search' }, @@ -20,6 +22,7 @@ const createSelectedTab = (activeTab?: TabKey): Record => ({ search: activeTab === 'search', substitute: activeTab === 'substitute', applicant: activeTab === 'applicant', + chat: activeTab === 'chat', }) interface DocStoreState { diff --git a/src/shared/types/tab.ts b/src/shared/types/tab.ts index 24ea3ce..bfd7e0c 100644 --- a/src/shared/types/tab.ts +++ b/src/shared/types/tab.ts @@ -1,10 +1,17 @@ -export type TabKey = 'home' | 'my' | 'search' | 'substitute' | 'applicant' +export type TabKey = + | 'home' + | 'my' + | 'search' + | 'substitute' + | 'applicant' + | 'chat' export const TAB_TITLE_MAP: Record = { home: '홈', my: 'MY', search: '알바 찾기', substitute: '대타', - /** 사장님(MANAGER) 전용 탭 */ + /** 사장님(MANAGER) 전용 탭 — 현재 Docbar에서는 채팅 탭으로 대체됨 */ applicant: '지원자', + chat: '채팅', } diff --git a/src/shared/ui/common/Docbar.tsx b/src/shared/ui/common/Docbar.tsx index 08ea657..8bfb60b 100644 --- a/src/shared/ui/common/Docbar.tsx +++ b/src/shared/ui/common/Docbar.tsx @@ -1,4 +1,5 @@ import ApplicantIcon from '@/assets/icons/doc/Applicant.svg?react' +import ChatIcon from '@/assets/icons/doc/Chat.svg?react' import HomeIcon from '@/assets/icons/doc/Home.svg?react' import MYIcon from '@/assets/icons/doc/MY.svg?react' import SearchIcon from '@/assets/icons/doc/Search.svg?react' @@ -7,11 +8,16 @@ import type { ComponentType, SVGProps } from 'react' import { useEffect, useMemo } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { useDocStore } from '@/shared/stores/useDocStore' +import { + selectTotalChatUnread, + useChatUnreadStore, +} from '@/shared/stores/useChatUnreadStore' import { typography } from '@/shared/lib/tokens' import { TAB_TITLE_MAP, type TabKey } from '@/shared/types/tab' import { homePathForScope } from '@/shared/lib/homePath' import { ROUTES } from '@/shared/constants/routes' import useAuthStore from '@/shared/stores/useAuthStore' +import { UnreadBadge } from '@/shared/ui/common/UnreadBadge' function DocContent({ icon, @@ -19,6 +25,7 @@ function DocContent({ isSelected, titleKey, label, + badgeCount = 0, onClick, }: { icon: ComponentType> @@ -26,6 +33,7 @@ function DocContent({ isSelected: boolean titleKey: TabKey label?: string + badgeCount?: number onClick: () => void }) { const Icon = icon @@ -36,10 +44,17 @@ function DocContent({ className="flex min-w-0 flex-1 flex-col items-center gap-1 cursor-pointer h-[78px] pt-2.5 pb-3" onClick={onClick} > - + + + +

> + /** 탭별 미읽음 뱃지 수 — 0·미지정이면 뱃지를 숨깁니다 */ + badgeCountByTab?: Partial> } export function DocbarView({ @@ -70,12 +87,14 @@ export function DocbarView({ onTabClick, tabs, labelByTab, + badgeCountByTab, }: DocbarViewProps) { const iconByTab: Record>> = { home: HomeIcon, search: SearchIcon, applicant: ApplicantIcon, substitute: SubstituteIcon, + chat: ChatIcon, my: MYIcon, } @@ -84,6 +103,7 @@ export function DocbarView({ search: 'Search', applicant: 'Applicant', substitute: 'Substitute', + chat: 'Chat', my: 'MY', } @@ -98,6 +118,7 @@ export function DocbarView({ isSelected={selectedTab[tab]} titleKey={tab} label={labelByTab?.[tab]} + badgeCount={badgeCountByTab?.[tab]} onClick={() => onTabClick(tab)} /> ))} @@ -114,6 +135,7 @@ export function Docbar() { state => state.setSelectedTabByPathname ) const { scope } = useAuthStore() + const chatUnreadCount = useChatUnreadStore(selectTotalChatUnread) useEffect(() => { setSelectedTabByPathname(pathname) @@ -121,13 +143,10 @@ export function Docbar() { const isManager = scope === 'MANAGER' - /** 지원자 탭은 사장님 전용 — 일반 유저는 기존 4탭을 유지합니다 */ + /** 채팅은 USER·MANAGER 공용 탭 — 사장님 지원자 목록은 내 공고 화면에서 진입합니다 */ const tabs = useMemo( - () => - isManager - ? ['home', 'search', 'applicant', 'substitute', 'my'] - : ['home', 'search', 'substitute', 'my'], - [isManager] + () => ['home', 'search', 'substitute', 'chat', 'my'], + [] ) const pathByTab: Record = useMemo( @@ -135,11 +154,12 @@ export function Docbar() { home: homePathForScope(scope), // 사장님은 구인구직(내 공고 목록), 일반 유저는 기존 알바찾기 경로 유지 search: isManager ? ROUTES.MANAGER.POSTINGS : ROUTES.USER.JOB_LOOKUP_MAP, - // 사장님 전용 탭 — 일반 유저에게는 렌더링되지 않습니다 + // 사장님 전용 — Docbar 에서는 제거됐고 내 공고 화면에서 진입합니다 applicant: ROUTES.MANAGER.POSTING_APPLICATIONS, substitute: isManager ? ROUTES.MANAGER.SUBSTITUTE_REQUEST : ROUTES.USER.SUBSTITUTE_REQUEST, + chat: ROUTES.CHAT.ROOMS, my: ROUTES.MY.ROOT, }), [scope, isManager] @@ -151,6 +171,11 @@ export function Docbar() { [isManager] ) + const badgeCountByTab = useMemo>>( + () => ({ chat: chatUnreadCount }), + [chatUnreadCount] + ) + const onTabClick = (tab: TabKey) => { navigate(pathByTab[tab]) } @@ -161,6 +186,7 @@ export function Docbar() { onTabClick={onTabClick} tabs={tabs} labelByTab={labelByTab} + badgeCountByTab={badgeCountByTab} /> ) } diff --git a/src/shared/ui/common/UnreadBadge.tsx b/src/shared/ui/common/UnreadBadge.tsx new file mode 100644 index 0000000..dae6498 --- /dev/null +++ b/src/shared/ui/common/UnreadBadge.tsx @@ -0,0 +1,36 @@ +import { cn } from '@/shared/lib/utils' +import { formatUnreadCount } from '@/shared/lib/unreadCount' + +interface UnreadBadgeProps { + count: number + /** 'md': 채팅 목록 행 · 'sm': Docbar·세그먼트 라벨 옆 */ + size?: 'md' | 'sm' + className?: string +} + +export function UnreadBadge({ + count, + size = 'md', + className, +}: UnreadBadgeProps) { + if (count <= 0) return null + + const label = formatUnreadCount(count) + + return ( + + {label} + + ) +} + +export type { UnreadBadgeProps } diff --git a/storybook/stories/Docbar.stories.tsx b/storybook/stories/Docbar.stories.tsx index 35f3c84..c3b94fa 100644 --- a/storybook/stories/Docbar.stories.tsx +++ b/storybook/stories/Docbar.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' import { DocbarView, @@ -12,16 +13,11 @@ const createSelectedTab = (activeTab: TabKey): DocbarSelectedTab => ({ search: activeTab === 'search', substitute: activeTab === 'substitute', applicant: activeTab === 'applicant', + chat: activeTab === 'chat', }) -/** 사장님(MANAGER) 탭 구성 — 지원자 탭 포함 5탭 */ -const MANAGER_TABS: TabKey[] = [ - 'home', - 'search', - 'applicant', - 'substitute', - 'my', -] +/** 현재 Docbar 구성 — 채팅은 USER·MANAGER 공용 5탭 */ +const TABS: TabKey[] = ['home', 'search', 'substitute', 'chat', 'my'] const meta = { title: 'shared/ui/common/Docbar', @@ -30,58 +26,74 @@ const meta = { layout: 'centered', }, tags: ['autodocs'], + args: { + onTabClick: () => {}, + tabs: TABS, + }, } satisfies Meta export default meta type Story = StoryObj export const HomeSelected: Story = { - args: { - selectedTab: createSelectedTab('home'), - onTabClick: () => {}, - tabs: ['home', 'search', 'substitute', 'my'], - }, + args: { selectedTab: createSelectedTab('home') }, } export const SearchSelected: Story = { - args: { - selectedTab: createSelectedTab('search'), - onTabClick: () => {}, - tabs: ['home', 'search', 'substitute', 'my'], - }, + args: { selectedTab: createSelectedTab('search') }, } export const SubstituteSelected: Story = { - args: { - selectedTab: createSelectedTab('substitute'), - onTabClick: () => {}, - tabs: ['home', 'search', 'substitute', 'my'], - }, + args: { selectedTab: createSelectedTab('substitute') }, } export const MySelected: Story = { + args: { selectedTab: createSelectedTab('my') }, +} + +export const ChatSelected: Story = { + args: { selectedTab: createSelectedTab('chat') }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByText('채팅')).toBeVisible() + }, +} + +/** 채팅 뱃지는 개인+전체 미읽음 합산값입니다 */ +export const ChatWithUnreadBadge: Story = { args: { - selectedTab: createSelectedTab('my'), - onTabClick: () => {}, - tabs: ['home', 'search', 'substitute', 'my'], + selectedTab: createSelectedTab('home'), + badgeCountByTab: { chat: 4 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByLabelText('읽지 않은 메시지 4개')).toBeVisible() }, } -/** 사장님 5탭 — '알바 찾기'가 '내 공고'로 노출되고 지원자 탭이 추가됩니다 */ -export const ManagerApplicantSelected: Story = { +export const ChatWithOverflowBadge: Story = { args: { - selectedTab: createSelectedTab('applicant'), - onTabClick: () => {}, - tabs: MANAGER_TABS, - labelByTab: { search: '내 공고' }, + selectedTab: createSelectedTab('home'), + badgeCountByTab: { chat: 150 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByText('99+')).toBeVisible() }, } +/** 사장님은 '알바 찾기'가 '내 공고'로 노출됩니다 */ export const ManagerPostingsSelected: Story = { args: { selectedTab: createSelectedTab('search'), - onTabClick: () => {}, - tabs: MANAGER_TABS, labelByTab: { search: '내 공고' }, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByText('내 공고')).toBeVisible() + }, } From 643dccf9518eb09b0824486d33c96eca45958a2b Mon Sep 17 00:00:00 2001 From: Dohyeon Date: Wed, 19 Aug 2026 13:28:05 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20API=C2=B7?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=93=88=20=EB=B0=8F=20?= =?UTF-8?q?STOMP=20=EC=97=B0=EA=B2=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 방 목록·메시지·읽음·생성 API와 실시간 구독/발행을 추가한다. 서버 스키마(opponentUserId, APP scope, isMine)에 맞춘 어댑터와 전체 채팅 목업 스토어를 포함한다. Co-authored-by: Cursor --- package-lock.json | 7 + package.json | 1 + src/features/chat/api/chatContacts.ts | 125 +++++++ src/features/chat/api/chatRoom.ts | 104 ++++++ .../mutation/useCreateChatRoomMutation.ts | 27 ++ .../mutation/useMarkChatRoomReadMutation.ts | 24 ++ .../chat/hooks/query/useChatContactsQuery.ts | 53 +++ .../chat/hooks/query/useChatMessagesQuery.ts | 68 ++++ .../hooks/query/useChatRoomDetailQuery.ts | 31 ++ .../chat/hooks/query/useChatRoomsQuery.ts | 46 +++ .../chat/hooks/useChatListViewModel.ts | 101 ++++++ .../chat/hooks/useChatRoomViewModel.ts | 315 ++++++++++++++++++ src/features/chat/hooks/useChatStomp.ts | 91 +++++ .../chat/hooks/useNewChatViewModel.ts | 79 +++++ src/features/chat/lib/adaptChat.ts | 117 +++++++ src/features/chat/lib/chatErrorMessage.ts | 23 ++ src/features/chat/lib/chatTime.ts | 71 ++++ src/features/chat/lib/chatTimeline.ts | 79 +++++ src/features/chat/lib/messageDraft.ts | 18 + src/features/chat/lib/segmentPreference.ts | 24 ++ src/features/chat/lib/stompDestinations.ts | 15 + src/features/chat/mock/groupChatMockStore.ts | 180 ++++++++++ src/features/chat/types/chat.ts | 111 ++++++ src/features/chat/types/dto.ts | 96 ++++++ src/shared/lib/stompConnection.ts | 159 +++++++++ 25 files changed, 1965 insertions(+) create mode 100644 src/features/chat/api/chatContacts.ts create mode 100644 src/features/chat/api/chatRoom.ts create mode 100644 src/features/chat/hooks/mutation/useCreateChatRoomMutation.ts create mode 100644 src/features/chat/hooks/mutation/useMarkChatRoomReadMutation.ts create mode 100644 src/features/chat/hooks/query/useChatContactsQuery.ts create mode 100644 src/features/chat/hooks/query/useChatMessagesQuery.ts create mode 100644 src/features/chat/hooks/query/useChatRoomDetailQuery.ts create mode 100644 src/features/chat/hooks/query/useChatRoomsQuery.ts create mode 100644 src/features/chat/hooks/useChatListViewModel.ts create mode 100644 src/features/chat/hooks/useChatRoomViewModel.ts create mode 100644 src/features/chat/hooks/useChatStomp.ts create mode 100644 src/features/chat/hooks/useNewChatViewModel.ts create mode 100644 src/features/chat/lib/adaptChat.ts create mode 100644 src/features/chat/lib/chatErrorMessage.ts create mode 100644 src/features/chat/lib/chatTime.ts create mode 100644 src/features/chat/lib/chatTimeline.ts create mode 100644 src/features/chat/lib/messageDraft.ts create mode 100644 src/features/chat/lib/segmentPreference.ts create mode 100644 src/features/chat/lib/stompDestinations.ts create mode 100644 src/features/chat/mock/groupChatMockStore.ts create mode 100644 src/features/chat/types/chat.ts create mode 100644 src/features/chat/types/dto.ts create mode 100644 src/shared/lib/stompConnection.ts diff --git a/package-lock.json b/package-lock.json index c740b9d..cc53816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "alter-client", "version": "0.0.0", "dependencies": { + "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "clsx": "^2.1.1", @@ -2590,6 +2591,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, "node_modules/@storybook/addon-a11y": { "version": "10.3.6", "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.3.6.tgz", diff --git a/package.json b/package.json index 5b1f3fe..d25641e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "**/*.{js,jsx,json,css}": "prettier --write" }, "dependencies": { + "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "clsx": "^2.1.1", diff --git a/src/features/chat/api/chatContacts.ts b/src/features/chat/api/chatContacts.ts new file mode 100644 index 0000000..d2ccd7a --- /dev/null +++ b/src/features/chat/api/chatContacts.ts @@ -0,0 +1,125 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { CommonApiResponse } from '@/shared/types/common' +import type { ChatContact } from '@/features/chat/types/chat' +import type { ChatApiScope } from '@/features/chat/api/chatRoom' + +/** + * 새 채팅 상대 후보 조회. + * 채팅 전용 엔드포인트가 아직 없어 기존 업장 멤버 API를 스코프별로 조합합니다. + * - USER: 같은 근무지의 동료(알바생) + 점주 + * - MANAGER: 소속 업장의 알바생 + */ + +const MEMBER_PAGE_SIZE = 100 + +interface CursorBody { + page: { cursor: string | null; pageSize: number; totalCount: number } + data: T[] +} + +interface MemberPersonDto { + id: number + name: string + profileImageUrl?: string | null +} + +interface UserWorkspaceItemDto { + workspaceId: number + businessName: string +} + +interface ManagerWorkspaceItemDto { + id: number + businessName: string +} + +function toContact( + person: MemberPersonDto, + scope: 'USER' | 'MANAGER', + workspaceName: string +): ChatContact { + return { + key: `${scope}:${person.id}`, + id: person.id, + scope, + name: person.name, + profileImageUrl: person.profileImageUrl ?? null, + workspaceName, + } +} + +/** 중복 인물(여러 업장에 함께 근무)은 첫 업장 기준으로 한 번만 노출합니다 */ +function dedupeContacts(contacts: ChatContact[]): ChatContact[] { + const seen = new Set() + return contacts.filter(contact => { + if (seen.has(contact.key)) return false + seen.add(contact.key) + return true + }) +} + +async function fetchUserContacts(): Promise { + const workspacesResponse = await axiosInstance.get< + CommonApiResponse> + >('/app/users/me/workspaces', { params: { pageSize: MEMBER_PAGE_SIZE } }) + + const workspaces = workspacesResponse.data.data.data + + const perWorkspace = await Promise.all( + workspaces.map(async workspace => { + const [workers, managers] = await Promise.all([ + axiosInstance.get< + CommonApiResponse> + >(`/app/users/me/workspaces/${workspace.workspaceId}/workers`, { + params: { pageSize: MEMBER_PAGE_SIZE }, + }), + axiosInstance.get< + CommonApiResponse> + >(`/app/users/me/workspaces/${workspace.workspaceId}/managers`, { + params: { pageSize: MEMBER_PAGE_SIZE }, + }), + ]) + + return [ + ...managers.data.data.data.map(item => + toContact(item.manager, 'MANAGER', workspace.businessName) + ), + ...workers.data.data.data.map(item => + toContact(item.user, 'USER', workspace.businessName) + ), + ] + }) + ) + + return dedupeContacts(perWorkspace.flat()) +} + +async function fetchManagerContacts(): Promise { + const workspacesResponse = await axiosInstance.get< + CommonApiResponse + >('/manager/workspaces') + + const workspaces = workspacesResponse.data.data + + const perWorkspace = await Promise.all( + workspaces.map(async workspace => { + const workers = await axiosInstance.get< + CommonApiResponse> + >(`/manager/workspaces/${workspace.id}/workers`, { + params: { pageSize: MEMBER_PAGE_SIZE, status: 'EMPLOYED' }, + }) + + return workers.data.data.data.map(item => + toContact(item.user, 'USER', workspace.businessName) + ) + }) + ) + + return dedupeContacts(perWorkspace.flat()) +} + +export async function fetchChatContacts( + scope: ChatApiScope +): Promise { + return scope === 'MANAGER' ? fetchManagerContacts() : fetchUserContacts() +} diff --git a/src/features/chat/api/chatRoom.ts b/src/features/chat/api/chatRoom.ts new file mode 100644 index 0000000..cf9f3a2 --- /dev/null +++ b/src/features/chat/api/chatRoom.ts @@ -0,0 +1,104 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import { getAuthApiBasePath } from '@/shared/lib/authApiPath' +import { unwrapCursorPage, type CursorPage } from '@/shared/lib/cursorPage' +import type { CommonApiResponse } from '@/shared/types/common' +import type { + ChatMessageDto, + ChatRoomDetailDto, + ChatRoomListItemDto, + CreateChatRoomRequest, + CreateChatRoomResponseDto, + MarkChatRoomReadRequest, +} from '@/features/chat/types/dto' + +export type ChatApiScope = 'MANAGER' | 'USER' | null | undefined + +/** `/app/chat` | `/manager/chat` */ +function chatBasePath(scope: ChatApiScope): string { + return `/${getAuthApiBasePath(scope)}/chat` +} + +/** 채팅 엔드포인트는 CommonApiResponse 로 감싼 응답과 날 응답이 섞여 있어 둘 다 받습니다 */ +function unwrapData(body: CommonApiResponse | T): T { + return 'data' in body ? body.data : body +} + +export interface ChatRoomsQueryParams { + pageSize: number + cursor?: string +} + +/** GET /{app|manager}/chat/rooms */ +export async function fetchChatRooms( + scope: ChatApiScope, + params: ChatRoomsQueryParams +): Promise> { + const response = await axiosInstance.get< + | CursorPage + | CommonApiResponse> + >(`${chatBasePath(scope)}/rooms`, { + params: { + pageSize: params.pageSize, + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + }) + return unwrapCursorPage(response.data) +} + +export interface ChatMessagesQueryParams { + pageSize: number + /** 위로 당겨 과거 메시지를 가져올 때의 커서 */ + cursor?: string +} + +/** GET /{app|manager}/chat/rooms/{roomId}/messages */ +export async function fetchChatMessages( + scope: ChatApiScope, + roomId: number, + params: ChatMessagesQueryParams +): Promise> { + const response = await axiosInstance.get< + CursorPage | CommonApiResponse> + >(`${chatBasePath(scope)}/rooms/${roomId}/messages`, { + params: { + pageSize: params.pageSize, + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + }) + return unwrapCursorPage(response.data) +} + +/** GET /{app|manager}/chat/rooms/{roomId} — 목록 캐시 없이 진입했을 때의 방 정보 */ +export async function fetchChatRoomDetail( + scope: ChatApiScope, + roomId: number +): Promise { + const response = await axiosInstance.get< + CommonApiResponse | ChatRoomDetailDto + >(`${chatBasePath(scope)}/rooms/${roomId}`) + return unwrapData(response.data) +} + +/** + * POST /{app|manager}/chat/rooms/{roomId}/read + * 서버가 last_read 를 갱신하므로 어디까지 읽었는지 함께 보냅니다. 멱등·clamp 처리됩니다. + */ +export async function markChatRoomRead( + scope: ChatApiScope, + roomId: number, + lastReadMessageId: number +): Promise { + const body: MarkChatRoomReadRequest = { lastReadMessageId } + await axiosInstance.post(`${chatBasePath(scope)}/rooms/${roomId}/read`, body) +} + +/** POST /{app|manager}/chat/rooms — 이미 방이 있으면 서버가 기존 방을 반환합니다 */ +export async function createChatRoom( + scope: ChatApiScope, + body: CreateChatRoomRequest +): Promise { + const response = await axiosInstance.post< + CommonApiResponse | CreateChatRoomResponseDto + >(`${chatBasePath(scope)}/rooms`, body) + return unwrapData(response.data) +} diff --git a/src/features/chat/hooks/mutation/useCreateChatRoomMutation.ts b/src/features/chat/hooks/mutation/useCreateChatRoomMutation.ts new file mode 100644 index 0000000..852365b --- /dev/null +++ b/src/features/chat/hooks/mutation/useCreateChatRoomMutation.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createChatRoom } from '@/features/chat/api/chatRoom' +import { toServerScope } from '@/features/chat/lib/adaptChat' +import type { ChatParticipantScope } from '@/features/chat/types/dto' +import { queryKeys } from '@/shared/lib/queryKeys' +import useAuthStore from '@/shared/stores/useAuthStore' + +export interface CreateChatRoomVariables { + opponentId: number + opponentScope: ChatParticipantScope +} + +export function useCreateChatRoomMutation() { + const queryClient = useQueryClient() + const scope = useAuthStore(state => state.scope) + + return useMutation({ + mutationFn: (variables: CreateChatRoomVariables) => + createChatRoom(scope, { + opponentUserId: variables.opponentId, + opponentScope: toServerScope(variables.opponentScope), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.chat.all }) + }, + }) +} diff --git a/src/features/chat/hooks/mutation/useMarkChatRoomReadMutation.ts b/src/features/chat/hooks/mutation/useMarkChatRoomReadMutation.ts new file mode 100644 index 0000000..1653204 --- /dev/null +++ b/src/features/chat/hooks/mutation/useMarkChatRoomReadMutation.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { markChatRoomRead } from '@/features/chat/api/chatRoom' +import { queryKeys } from '@/shared/lib/queryKeys' +import useAuthStore from '@/shared/stores/useAuthStore' + +export interface MarkChatRoomReadVariables { + roomId: number + /** 서버가 last_read 를 이 지점까지 올립니다 */ + lastReadMessageId: number +} + +export function useMarkChatRoomReadMutation() { + const queryClient = useQueryClient() + const scope = useAuthStore(state => state.scope) + + return useMutation({ + mutationFn: ({ roomId, lastReadMessageId }: MarkChatRoomReadVariables) => + markChatRoomRead(scope, roomId, lastReadMessageId), + onSuccess: () => { + // 방 진입으로 미읽음이 0이 되므로 목록·Docbar 뱃지를 갱신합니다 + void queryClient.invalidateQueries({ queryKey: queryKeys.chat.roomsAll }) + }, + }) +} diff --git a/src/features/chat/hooks/query/useChatContactsQuery.ts b/src/features/chat/hooks/query/useChatContactsQuery.ts new file mode 100644 index 0000000..d240fab --- /dev/null +++ b/src/features/chat/hooks/query/useChatContactsQuery.ts @@ -0,0 +1,53 @@ +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { fetchChatContacts } from '@/features/chat/api/chatContacts' +import type { ChatContact, ChatRoomListItem } from '@/features/chat/types/chat' +import { queryKeys } from '@/shared/lib/queryKeys' +import useAuthStore from '@/shared/stores/useAuthStore' + +interface UseChatContactsQueryOptions { + enabled?: boolean + /** 이미 방이 있는 상대에 '대화중'을 표시하기 위한 기존 방 목록 */ + existingRooms?: ChatRoomListItem[] +} + +export function useChatContactsQuery({ + enabled = true, + existingRooms = [], +}: UseChatContactsQueryOptions = {}) { + const isLoggedIn = useAuthStore(state => state.isLoggedIn) + const scope = useAuthStore(state => state.scope) + + const query = useQuery({ + queryKey: queryKeys.chat.contacts(scope), + queryFn: () => fetchChatContacts(scope), + enabled: enabled && isLoggedIn && Boolean(scope), + staleTime: 60_000, + }) + + const roomIdByParticipant = useMemo(() => { + const map = new Map() + existingRooms.forEach(room => { + if (room.opponentId !== undefined && room.opponentScope) { + map.set(`${room.opponentScope}:${room.opponentId}`, room.id) + } + }) + return map + }, [existingRooms]) + + const contacts = useMemo( + () => + (query.data ?? []).map(contact => ({ + ...contact, + existingRoomId: roomIdByParticipant.get(contact.key), + })), + [query.data, roomIdByParticipant] + ) + + return { + contacts, + isLoading: query.isPending && query.fetchStatus !== 'idle', + isError: query.isError, + refetch: query.refetch, + } +} diff --git a/src/features/chat/hooks/query/useChatMessagesQuery.ts b/src/features/chat/hooks/query/useChatMessagesQuery.ts new file mode 100644 index 0000000..1324264 --- /dev/null +++ b/src/features/chat/hooks/query/useChatMessagesQuery.ts @@ -0,0 +1,68 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchChatMessages } from '@/features/chat/api/chatRoom' +import { + adaptChatMessage, + type AdaptChatMessageOptions, +} from '@/features/chat/lib/adaptChat' +import { sortMessagesAscending } from '@/features/chat/lib/chatTimeline' +import type { ChatMessage } from '@/features/chat/types/chat' +import { queryKeys } from '@/shared/lib/queryKeys' +import useAuthStore from '@/shared/stores/useAuthStore' + +const PAGE_SIZE = 30 + +interface UseChatMessagesQueryOptions extends AdaptChatMessageOptions { + roomId: number + enabled?: boolean +} + +export function useChatMessagesQuery({ + roomId, + enabled = true, + ...adaptOptions +}: UseChatMessagesQueryOptions) { + const isLoggedIn = useAuthStore(state => state.isLoggedIn) + const scope = useAuthStore(state => state.scope) + + const query = useInfiniteQuery({ + queryKey: queryKeys.chat.messages(scope, roomId, { pageSize: PAGE_SIZE }), + queryFn: ({ pageParam }) => + fetchChatMessages(scope, roomId, { + pageSize: PAGE_SIZE, + cursor: pageParam, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page?.cursor ?? undefined, + enabled: enabled && isLoggedIn && Boolean(scope) && Number.isFinite(roomId), + }) + + /** 서버는 최신순 커서 페이지를 주므로 화면 표시용으로 오래된 → 최신으로 정렬합니다 */ + const messages = useMemo(() => { + const flattened = + query.data?.pages.flatMap(page => + page.data.map(dto => adaptChatMessage(dto, adaptOptions)) + ) ?? [] + return sortMessagesAscending(flattened) + // adaptOptions 는 매 렌더 새 객체라 값 기준으로 의존성을 나열합니다 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + query.data, + adaptOptions.myId, + adaptOptions.myScope, + adaptOptions.opponentId, + adaptOptions.opponentScope, + adaptOptions.opponentName, + ]) + + return { + messages, + isLoading: query.isPending && query.fetchStatus !== 'idle', + isError: query.isError, + refetch: query.refetch, + /** 위로 당겨 과거 메시지 추가 로드 */ + hasOlderMessages: query.hasNextPage, + isFetchingOlderMessages: query.isFetchingNextPage, + fetchOlderMessages: query.fetchNextPage, + } +} diff --git a/src/features/chat/hooks/query/useChatRoomDetailQuery.ts b/src/features/chat/hooks/query/useChatRoomDetailQuery.ts new file mode 100644 index 0000000..5dd11b1 --- /dev/null +++ b/src/features/chat/hooks/query/useChatRoomDetailQuery.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchChatRoomDetail } from '@/features/chat/api/chatRoom' +import { adaptChatRoomDetail } from '@/features/chat/lib/adaptChat' +import { queryKeys } from '@/shared/lib/queryKeys' +import useAuthStore from '@/shared/stores/useAuthStore' + +interface UseChatRoomDetailQueryOptions { + roomId: number + enabled?: boolean +} + +/** + * 목록을 거치지 않고 방에 바로 진입(딥링크·새로고침)했을 때 헤더 제목과 상대 정보를 채웁니다. + * 목록 캐시에 방이 있으면 호출하지 않습니다. + */ +export function useChatRoomDetailQuery({ + roomId, + enabled = true, +}: UseChatRoomDetailQueryOptions) { + const isLoggedIn = useAuthStore(state => state.isLoggedIn) + const scope = useAuthStore(state => state.scope) + + const query = useQuery({ + queryKey: queryKeys.chat.roomDetail(scope, roomId), + queryFn: () => fetchChatRoomDetail(scope, roomId), + select: adaptChatRoomDetail, + enabled: enabled && isLoggedIn && Boolean(scope) && Number.isFinite(roomId), + }) + + return { room: query.data, isLoading: query.isLoading } +} diff --git a/src/features/chat/hooks/query/useChatRoomsQuery.ts b/src/features/chat/hooks/query/useChatRoomsQuery.ts new file mode 100644 index 0000000..9db0272 --- /dev/null +++ b/src/features/chat/hooks/query/useChatRoomsQuery.ts @@ -0,0 +1,46 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchChatRooms } from '@/features/chat/api/chatRoom' +import { adaptChatRoomListItem } from '@/features/chat/lib/adaptChat' +import type { ChatRoomListItem } from '@/features/chat/types/chat' +import { queryKeys } from '@/shared/lib/queryKeys' +import useAuthStore from '@/shared/stores/useAuthStore' + +const PAGE_SIZE = 20 + +export function useChatRoomsQuery() { + const isLoggedIn = useAuthStore(state => state.isLoggedIn) + const scope = useAuthStore(state => state.scope) + + const query = useInfiniteQuery({ + queryKey: queryKeys.chat.rooms(scope, { + chatScope: 'personal', + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + fetchChatRooms(scope, { pageSize: PAGE_SIZE, cursor: pageParam }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page?.cursor ?? undefined, + enabled: isLoggedIn && Boolean(scope), + }) + + const rooms = useMemo( + () => + query.data?.pages.flatMap(page => page.data.map(adaptChatRoomListItem)) ?? + [], + [query.data] + ) + + const totalCount = query.data?.pages[0]?.page?.totalCount ?? rooms.length + + return { + rooms, + totalCount, + isLoading: query.isPending && query.fetchStatus !== 'idle', + isError: query.isError, + refetch: query.refetch, + hasNextPage: query.hasNextPage, + isFetchingNextPage: query.isFetchingNextPage, + fetchNextPage: query.fetchNextPage, + } +} diff --git a/src/features/chat/hooks/useChatListViewModel.ts b/src/features/chat/hooks/useChatListViewModel.ts new file mode 100644 index 0000000..36bd3a6 --- /dev/null +++ b/src/features/chat/hooks/useChatListViewModel.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useChatRoomsQuery } from '@/features/chat/hooks/query/useChatRoomsQuery' +import { + readLastChatSegment, + writeLastChatSegment, +} from '@/features/chat/lib/segmentPreference' +import { + buildGroupChatRooms, + useGroupChatMockStore, +} from '@/features/chat/mock/groupChatMockStore' +import type { ChatRoomListItem, ChatSegment } from '@/features/chat/types/chat' +import { useChatUnreadStore } from '@/shared/stores/useChatUnreadStore' + +function sumUnread(rooms: ChatRoomListItem[]): number { + return rooms.reduce((total, room) => total + room.unreadCount, 0) +} + +function matchesKeyword(room: ChatRoomListItem, keyword: string): boolean { + const normalized = keyword.trim().toLowerCase() + if (!normalized) return true + return ( + room.title.toLowerCase().includes(normalized) || + room.latestMessage.toLowerCase().includes(normalized) + ) +} + +/** 미읽음 있는 방을 위로, 그다음 최신 갱신 순 */ +function sortRooms(rooms: ChatRoomListItem[]): ChatRoomListItem[] { + return [...rooms].sort((a, b) => { + const unreadDiff = Number(b.unreadCount > 0) - Number(a.unreadCount > 0) + if (unreadDiff !== 0) return unreadDiff + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + }) +} + +export function useChatListViewModel() { + const [segment, setSegment] = useState(readLastChatSegment) + const [keyword, setKeyword] = useState('') + + const personalQuery = useChatRoomsQuery() + const messagesByRoomId = useGroupChatMockStore( + state => state.messagesByRoomId + ) + const unreadByRoomId = useGroupChatMockStore(state => state.unreadByRoomId) + const groupRooms = useMemo( + () => buildGroupChatRooms(messagesByRoomId, unreadByRoomId), + [messagesByRoomId, unreadByRoomId] + ) + const setUnreadCount = useChatUnreadStore(state => state.setUnreadCount) + + const personalUnread = useMemo( + () => sumUnread(personalQuery.rooms), + [personalQuery.rooms] + ) + const groupUnread = useMemo(() => sumUnread(groupRooms), [groupRooms]) + + // Docbar 채팅 뱃지 = 개인 + 전체 합산 + useEffect(() => { + setUnreadCount('personal', personalUnread) + }, [personalUnread, setUnreadCount]) + + useEffect(() => { + setUnreadCount('group', groupUnread) + }, [groupUnread, setUnreadCount]) + + const changeSegment = useCallback((next: ChatSegment) => { + setSegment(next) + setKeyword('') + writeLastChatSegment(next) + }, []) + + const isPersonal = segment === 'personal' + const sourceRooms = isPersonal ? personalQuery.rooms : groupRooms + + const rooms = useMemo( + () => sortRooms(sourceRooms).filter(room => matchesKeyword(room, keyword)), + [sourceRooms, keyword] + ) + + const isLoading = isPersonal ? personalQuery.isLoading : false + const isError = isPersonal ? personalQuery.isError : false + const hasKeyword = keyword.trim().length > 0 + + return { + segment, + changeSegment, + keyword, + setKeyword, + rooms, + isLoading, + isError, + /** 검색 결과가 없는 경우와 방이 아예 없는 경우를 구분해 빈 상태 문구를 바꿉니다 */ + isEmpty: !isLoading && !isError && rooms.length === 0, + hasKeyword, + unreadCountBySegment: { personal: personalUnread, group: groupUnread }, + refetch: personalQuery.refetch, + hasNextPage: isPersonal && personalQuery.hasNextPage, + isFetchingNextPage: isPersonal && personalQuery.isFetchingNextPage, + fetchNextPage: personalQuery.fetchNextPage, + } +} diff --git a/src/features/chat/hooks/useChatRoomViewModel.ts b/src/features/chat/hooks/useChatRoomViewModel.ts new file mode 100644 index 0000000..a37db2c --- /dev/null +++ b/src/features/chat/hooks/useChatRoomViewModel.ts @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' + +import { useChatMessagesQuery } from '@/features/chat/hooks/query/useChatMessagesQuery' +import { useChatRoomDetailQuery } from '@/features/chat/hooks/query/useChatRoomDetailQuery' +import { useChatRoomsQuery } from '@/features/chat/hooks/query/useChatRoomsQuery' +import { useMarkChatRoomReadMutation } from '@/features/chat/hooks/mutation/useMarkChatRoomReadMutation' +import { useChatStomp } from '@/features/chat/hooks/useChatStomp' +import { adaptChatMessage } from '@/features/chat/lib/adaptChat' +import { + buildChatTimeline, + mergeChatMessages, + sortMessagesAscending, +} from '@/features/chat/lib/chatTimeline' +import { + findGroupChatRoomSeed, + isGroupChatRoomId, + useGroupChatMockStore, +} from '@/features/chat/mock/groupChatMockStore' +import type { + ChatConnectionState, + ChatMessage, + ChatRoomContext, +} from '@/features/chat/types/chat' +import { queryKeys } from '@/shared/lib/queryKeys' +import useAuthStore from '@/shared/stores/useAuthStore' +import { useUserMe } from '@/features/user/me/hooks/useUserMe' + +function createClientId(): string { + return `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +export function useChatRoomViewModel(roomId: number) { + const queryClient = useQueryClient() + const scope = useAuthStore(state => state.scope) + const { user } = useUserMe() + + const isGroupRoom = isGroupChatRoomId(roomId) + const groupSeed = findGroupChatRoomSeed(roomId) + + /** + * 방 전환 시 초기화해야 하는 화면 상태를 roomId 와 함께 묶어 둡니다. + * effect 로 리셋하면 이전 방의 메시지가 한 프레임 노출되므로 렌더 중 조정합니다. + */ + const [roomState, setRoomState] = useState(() => ({ + roomId, + draft: '', + isAttachmentOpen: false, + pendingMessages: [] as ChatMessage[], + /** STOMP 로 받은 메시지 — 재조회 전까지 화면에 즉시 반영 */ + liveMessages: [] as ChatMessage[], + })) + + if (roomState.roomId !== roomId) { + setRoomState({ + roomId, + draft: '', + isAttachmentOpen: false, + pendingMessages: [], + liveMessages: [], + }) + } + + const { draft, isAttachmentOpen, pendingMessages, liveMessages } = roomState + + const setDraft = useCallback((next: string) => { + setRoomState(current => ({ ...current, draft: next })) + }, []) + + const toggleAttachment = useCallback(() => { + setRoomState(current => ({ + ...current, + isAttachmentOpen: !current.isAttachmentOpen, + })) + }, []) + + const { rooms, isLoading: isRoomListLoading } = useChatRoomsQuery() + const listRoom = useMemo( + () => rooms.find(candidate => candidate.id === roomId), + [rooms, roomId] + ) + + // 방이 목록 첫 페이지에 없을 수 있어(딥링크·새로고침) 상세로 폴백합니다 + const { room: detailRoom } = useChatRoomDetailQuery({ + roomId, + enabled: !isGroupRoom && !isRoomListLoading && !listRoom, + }) + const room = listRoom ?? detailRoom + + const messagesQuery = useChatMessagesQuery({ + roomId, + enabled: !isGroupRoom, + myId: user.id, + myScope: scope === 'MANAGER' ? 'MANAGER' : 'USER', + opponentId: room?.opponentId, + opponentScope: room?.opponentScope, + opponentName: room?.title, + }) + + const groupMessages = useGroupChatMockStore( + state => state.messagesByRoomId[roomId] + ) + const sendGroupMessage = useGroupChatMockStore(state => state.sendMessage) + const markGroupRead = useGroupChatMockStore(state => state.markRead) + + const markReadMutation = useMarkChatRoomReadMutation() + const markReadMutate = markReadMutation.mutate + + /** + * 서버가 last_read 를 이 id 까지 올리므로 읽음 처리는 메시지를 받은 뒤에야 보낼 수 있습니다. + * 낙관적 메시지는 음수 id 라 제외됩니다. + */ + const lastReadTargetId = useMemo(() => { + let latest = 0 + for (const message of messagesQuery.messages) { + if (message.id > latest) latest = message.id + } + for (const message of liveMessages) { + if (message.id > latest) latest = message.id + } + return latest + }, [messagesQuery.messages, liveMessages]) + + const markedReadIdRef = useRef(0) + + useEffect(() => { + markedReadIdRef.current = 0 + }, [roomId]) + + // 방을 열어둔 채 새 메시지를 받아도 읽음 상태를 따라 올립니다 + useEffect(() => { + if (isGroupRoom || !Number.isFinite(roomId)) return + if (lastReadTargetId <= markedReadIdRef.current) return + + markedReadIdRef.current = lastReadTargetId + markReadMutate({ roomId, lastReadMessageId: lastReadTargetId }) + }, [roomId, isGroupRoom, lastReadTargetId, markReadMutate]) + + const hasMarkedGroupReadRef = useRef(false) + + useEffect(() => { + hasMarkedGroupReadRef.current = false + }, [roomId]) + + useEffect(() => { + if (!isGroupRoom || hasMarkedGroupReadRef.current) return + hasMarkedGroupReadRef.current = true + markGroupRead(roomId) + }, [roomId, isGroupRoom, markGroupRead]) + + const handleIncomingMessage = useCallback( + (dto: Parameters[0]) => { + const message = adaptChatMessage(dto, { + myId: user.id, + myScope: scope === 'MANAGER' ? 'MANAGER' : 'USER', + opponentId: room?.opponentId, + opponentScope: room?.opponentScope, + opponentName: room?.title, + }) + + setRoomState(current => { + if (current.liveMessages.some(existing => existing.id === message.id)) { + return current + } + return { + ...current, + liveMessages: [...current.liveMessages, message], + // 내가 보낸 메시지의 echo 가 도착하면 낙관적 항목을 제거합니다 + pendingMessages: message.isMine + ? current.pendingMessages.filter( + pending => pending.content !== message.content + ) + : current.pendingMessages, + } + }) + // 목록의 미리보기·정렬·미읽음을 갱신합니다 + void queryClient.invalidateQueries({ queryKey: queryKeys.chat.roomsAll }) + }, + [ + user.id, + scope, + room?.opponentId, + room?.opponentScope, + room?.title, + queryClient, + ] + ) + + const { connectionState, isConnected, sendMessage } = useChatStomp({ + roomId, + enabled: !isGroupRoom, + onMessage: handleIncomingMessage, + }) + + const messages = useMemo(() => { + if (isGroupRoom) return groupMessages ?? [] + + const serverMessages = sortMessagesAscending([ + ...messagesQuery.messages, + ...liveMessages.filter( + live => !messagesQuery.messages.some(loaded => loaded.id === live.id) + ), + ]) + return mergeChatMessages(serverMessages, pendingMessages) + }, [ + isGroupRoom, + groupMessages, + messagesQuery.messages, + liveMessages, + pendingMessages, + ]) + + const roomContext = useMemo(() => { + if (isGroupRoom) { + return { + id: roomId, + segment: 'group', + title: groupSeed?.workspaceName ?? '전체 채팅', + memberCount: groupSeed?.memberCount, + } + } + return { + id: roomId, + segment: 'personal', + title: room?.title ?? '채팅', + } + }, [isGroupRoom, roomId, groupSeed, room?.title]) + + const timeline = useMemo( + () => buildChatTimeline(messages, roomContext.segment), + [messages, roomContext.segment] + ) + + const handleSend = useCallback(() => { + const content = draft.trim() + if (!content) return + + if (isGroupRoom) { + setRoomState(current => ({ ...current, draft: '' })) + sendGroupMessage(roomId, content) + return + } + + const optimistic: ChatMessage = { + id: -Date.now(), + clientId: createClientId(), + senderId: user.id ?? -1, + senderScope: scope === 'MANAGER' ? 'MANAGER' : 'USER', + senderName: '', + senderProfileImageUrl: null, + content, + createdAt: new Date().toISOString(), + isMine: true, + status: sendMessage(content) ? 'pending' : 'failed', + messageType: 'NORMAL', + attachments: [], + } + setRoomState(current => ({ + ...current, + draft: '', + pendingMessages: [...current.pendingMessages, optimistic], + })) + }, [ + draft, + isGroupRoom, + sendGroupMessage, + roomId, + user.id, + scope, + sendMessage, + ]) + + const retryFailedMessage = useCallback( + (clientId: string) => { + setRoomState(current => ({ + ...current, + pendingMessages: current.pendingMessages.map(pending => + pending.clientId === clientId + ? { + ...pending, + status: sendMessage(pending.content) ? 'pending' : 'failed', + } + : pending + ), + })) + }, + [sendMessage] + ) + + return { + room: roomContext, + timeline, + messages, + isLoading: isGroupRoom ? false : messagesQuery.isLoading, + isError: isGroupRoom ? false : messagesQuery.isError, + isEmpty: messages.length === 0, + refetch: messagesQuery.refetch, + hasOlderMessages: isGroupRoom ? false : messagesQuery.hasOlderMessages, + isFetchingOlderMessages: isGroupRoom + ? false + : messagesQuery.isFetchingOlderMessages, + fetchOlderMessages: messagesQuery.fetchOlderMessages, + draft, + setDraft, + handleSend, + retryFailedMessage, + /** 전체 채팅은 목업이라 항상 연결된 것으로 표시합니다 */ + connectionState: isGroupRoom + ? ('connected' as ChatConnectionState) + : connectionState, + isConnected: isGroupRoom ? true : isConnected, + isAttachmentOpen, + toggleAttachment, + } +} diff --git a/src/features/chat/hooks/useChatStomp.ts b/src/features/chat/hooks/useChatStomp.ts new file mode 100644 index 0000000..41bc4f9 --- /dev/null +++ b/src/features/chat/hooks/useChatStomp.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react' +import { stompConnection } from '@/shared/lib/stompConnection' +import useAuthStore from '@/shared/stores/useAuthStore' +import { + chatPublishDestination, + chatSubscribeDestination, +} from '@/features/chat/lib/stompDestinations' +import type { ChatConnectionState } from '@/features/chat/types/chat' +import type { + ChatMessageDto, + SendChatMessagePayload, +} from '@/features/chat/types/dto' + +/** 이미지 전용 메시지는 content 가 null·누락일 수 있어 id·senderId 로만 판별합니다 */ +function parseChatMessage(body: string): ChatMessageDto | null { + try { + const parsed: unknown = JSON.parse(body) + if ( + typeof parsed === 'object' && + parsed !== null && + 'id' in parsed && + 'senderId' in parsed + ) { + return parsed as ChatMessageDto + } + return null + } catch { + return null + } +} + +interface UseChatStompOptions { + roomId: number + enabled?: boolean + onMessage: (message: ChatMessageDto) => void +} + +/** + * 채팅방 실시간 구독·발행. + * 연결은 전역 매니저가 관리하고, 이 훅은 방 단위 구독과 상태만 다룹니다. + */ +export function useChatStomp({ + roomId, + enabled = true, + onMessage, +}: UseChatStompOptions) { + const scope = useAuthStore(state => state.scope) + const status = useSyncExternalStore( + stompConnection.onStatusChange, + stompConnection.getStatus + ) + + // 최신 핸들러를 참조해 구독을 매 렌더 재생성하지 않습니다 + const onMessageRef = useRef(onMessage) + useEffect(() => { + onMessageRef.current = onMessage + }, [onMessage]) + + useEffect(() => { + if (!enabled || !Number.isFinite(roomId)) return + + stompConnection.acquire() + const unsubscribeRoom = stompConnection.subscribe( + chatSubscribeDestination(roomId), + body => { + const message = parseChatMessage(body) + if (message) onMessageRef.current(message) + } + ) + + return () => { + unsubscribeRoom() + stompConnection.release() + } + }, [enabled, roomId]) + + const sendMessage = useCallback( + (content: string) => { + const payload: SendChatMessagePayload = { content, type: 'NORMAL' } + return stompConnection.publish( + chatPublishDestination(scope, roomId), + payload + ) + }, + [scope, roomId] + ) + + const connectionState: ChatConnectionState = status + + return { connectionState, isConnected: status === 'connected', sendMessage } +} diff --git a/src/features/chat/hooks/useNewChatViewModel.ts b/src/features/chat/hooks/useNewChatViewModel.ts new file mode 100644 index 0000000..2e99601 --- /dev/null +++ b/src/features/chat/hooks/useNewChatViewModel.ts @@ -0,0 +1,79 @@ +import { useCallback, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { useChatContactsQuery } from '@/features/chat/hooks/query/useChatContactsQuery' +import { useChatRoomsQuery } from '@/features/chat/hooks/query/useChatRoomsQuery' +import { useCreateChatRoomMutation } from '@/features/chat/hooks/mutation/useCreateChatRoomMutation' +import { resolveChatErrorMessage } from '@/features/chat/lib/chatErrorMessage' +import type { ChatContact } from '@/features/chat/types/chat' +import { chatRoomPath } from '@/shared/constants/routes' +import { showToast } from '@/shared/stores/useToastStore' + +interface UseNewChatViewModelOptions { + enabled: boolean + onNavigated: () => void +} + +export function useNewChatViewModel({ + enabled, + onNavigated, +}: UseNewChatViewModelOptions) { + const navigate = useNavigate() + const [keyword, setKeyword] = useState('') + + const { rooms } = useChatRoomsQuery() + const { contacts, isLoading, isError, refetch } = useChatContactsQuery({ + enabled, + existingRooms: rooms, + }) + const createRoomMutation = useCreateChatRoomMutation() + + const filteredContacts = useMemo(() => { + const normalized = keyword.trim().toLowerCase() + if (!normalized) return contacts + return contacts.filter( + contact => + contact.name.toLowerCase().includes(normalized) || + contact.workspaceName.toLowerCase().includes(normalized) + ) + }, [contacts, keyword]) + + const selectContact = useCallback( + async (contact: ChatContact) => { + // 이미 방이 있으면 새로 만들지 않고 그 방으로 들어갑니다 + if (contact.existingRoomId !== undefined) { + onNavigated() + navigate(chatRoomPath(contact.existingRoomId)) + return + } + + try { + const created = await createRoomMutation.mutateAsync({ + opponentId: contact.id, + opponentScope: contact.scope, + }) + onNavigated() + navigate(chatRoomPath(created.chatRoomId)) + } catch (error) { + showToast( + resolveChatErrorMessage(error, '채팅방을 만들지 못했어요.'), + 'error' + ) + } + }, + [createRoomMutation, navigate, onNavigated] + ) + + return { + keyword, + setKeyword, + contacts: filteredContacts, + isLoading, + isError, + refetch, + isEmpty: !isLoading && !isError && filteredContacts.length === 0, + hasKeyword: keyword.trim().length > 0, + isCreating: createRoomMutation.isPending, + selectContact, + } +} diff --git a/src/features/chat/lib/adaptChat.ts b/src/features/chat/lib/adaptChat.ts new file mode 100644 index 0000000..5f02107 --- /dev/null +++ b/src/features/chat/lib/adaptChat.ts @@ -0,0 +1,117 @@ +import type { + ChatMessage, + ChatRoomDetail, + ChatRoomListItem, +} from '@/features/chat/types/chat' +import type { + ChatMessageDto, + ChatParticipantScope, + ChatRoomDetailDto, + ChatRoomListItemDto, + ChatScopeDto, + ChatServerScope, +} from '@/features/chat/types/dto' + +/** + * scope 를 도메인 표현으로 좁힙니다. + * 서버는 평문 `"APP"` 과 `{ value: "APP" }` 두 형태를 섞어 쓰고, 알바생은 APP 으로 옵니다. + */ +export function toParticipantScope( + value: ChatScopeDto | null | undefined +): ChatParticipantScope { + const raw = typeof value === 'string' ? value : value?.value + return raw === 'MANAGER' ? 'MANAGER' : 'USER' +} + +/** 요청 바디용 — 서버 enum 은 USER 대신 APP */ +export function toServerScope(scope: ChatParticipantScope): ChatServerScope { + return scope === 'MANAGER' ? 'MANAGER' : 'APP' +} + +export function adaptChatRoomListItem( + dto: ChatRoomListItemDto +): ChatRoomListItem { + return { + id: dto.id, + segment: 'personal', + title: dto.opponentName, + profileImageUrl: dto.opponentProfileImageUrl ?? null, + latestMessage: dto.latestMessageContent ?? '', + updatedAt: dto.updatedAt, + unreadCount: dto.unreadCount ?? 0, + opponentId: dto.opponentId, + opponentScope: toParticipantScope(dto.opponentScope), + } +} + +export function adaptChatRoomDetail(dto: ChatRoomDetailDto): ChatRoomDetail { + return { + id: dto.id, + title: dto.opponentName, + profileImageUrl: dto.opponentProfileImageUrl ?? null, + opponentId: dto.opponentId, + opponentScope: toParticipantScope(dto.opponentScope), + } +} + +export interface AdaptChatMessageOptions { + /** 로그인 사용자 id — 있으면 발신자 비교로 내 메시지를 판별 */ + myId?: number + /** 로그인 사용자 scope */ + myScope?: ChatParticipantScope + /** 1:1 방 상대 정보 — myId 를 모를 때 폴백 판별에 사용 */ + opponentId?: number + opponentScope?: ChatParticipantScope + /** 상대 이름 — DTO에 senderName 이 없을 때 표시용 폴백 */ + opponentName?: string +} + +/** + * 내 메시지 판별 우선순위 + * 1. 서버의 `isMine` + * 2. 로그인 사용자 id·scope 일치 + * 3. 1:1 방의 상대가 아니면 내 메시지 (단체방에서는 쓸 수 없어 마지막 순위) + */ +function resolveIsMine( + dto: ChatMessageDto, + senderScope: ChatParticipantScope, + options: AdaptChatMessageOptions +): boolean { + if (typeof dto.isMine === 'boolean') return dto.isMine + + if (options.myId !== undefined) { + return dto.senderId === options.myId && senderScope === options.myScope + } + + if (options.opponentId !== undefined) { + return !( + dto.senderId === options.opponentId && + senderScope === options.opponentScope + ) + } + + return false +} + +export function adaptChatMessage( + dto: ChatMessageDto, + options: AdaptChatMessageOptions = {} +): ChatMessage { + const senderScope = toParticipantScope(dto.senderScope) + const isMine = resolveIsMine(dto, senderScope, options) + + return { + id: dto.id, + senderId: dto.senderId, + senderScope, + senderName: dto.senderName ?? (isMine ? '' : (options.opponentName ?? '')), + senderProfileImageUrl: dto.senderProfileImageUrl ?? null, + content: dto.content ?? '', + createdAt: dto.createdAt, + isMine, + status: 'sent', + messageType: dto.type ?? 'NORMAL', + attachments: dto.attachments ?? [], + unreadCount: dto.unreadCount, + } +} diff --git a/src/features/chat/lib/chatErrorMessage.ts b/src/features/chat/lib/chatErrorMessage.ts new file mode 100644 index 0000000..2fc5cf9 --- /dev/null +++ b/src/features/chat/lib/chatErrorMessage.ts @@ -0,0 +1,23 @@ +import axios from 'axios' + +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import type { ErrorResponse } from '@/shared/types/common' + +const CHAT_ERROR_MESSAGES: Record = { + B030: '채팅방을 찾을 수 없어요.', + B031: '이 채팅방에 참여할 수 없어요.', + B032: '메시지를 보낼 수 없는 채팅방이에요.', +} + +export function resolveChatErrorMessage( + error: unknown, + fallback: string +): string { + if (axios.isAxiosError(error)) { + const code = (error.response?.data as ErrorResponse | undefined)?.code + if (code && CHAT_ERROR_MESSAGES[code]) { + return CHAT_ERROR_MESSAGES[code] + } + } + return getAxiosErrorMessage(error, fallback) +} diff --git a/src/features/chat/lib/chatTime.ts b/src/features/chat/lib/chatTime.ts new file mode 100644 index 0000000..55d03e1 --- /dev/null +++ b/src/features/chat/lib/chatTime.ts @@ -0,0 +1,71 @@ +/** 채팅 전용 시간 표기 — 말풍선 옆 시각, 날짜 구분선, 목록 상대 시각 */ + +const KOREAN_WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'] as const + +function toValidDate(value: string | Date): Date | null { + const date = value instanceof Date ? value : new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +function startOfDay(date: Date): number { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() +} + +/** 말풍선 옆 시각 — '오전 11:02' */ +export function formatMessageTime(value: string | Date): string { + const date = toValidDate(value) + if (!date) return '' + + const hours = date.getHours() + const minutes = String(date.getMinutes()).padStart(2, '0') + const meridiem = hours < 12 ? '오전' : '오후' + const displayHours = hours % 12 === 0 ? 12 : hours % 12 + + return `${meridiem} ${displayHours}:${minutes}` +} + +/** 날짜 구분선 — '2026년 8월 19일 (수)', 올해면 '8월 19일 (수)' */ +export function formatDateDivider( + value: string | Date, + now: Date = new Date() +): string { + const date = toValidDate(value) + if (!date) return '' + + const monthDay = `${date.getMonth() + 1}월 ${date.getDate()}일 (${KOREAN_WEEKDAYS[date.getDay()]})` + + return date.getFullYear() === now.getFullYear() + ? monthDay + : `${date.getFullYear()}년 ${monthDay}` +} + +/** 같은 날짜인지 — 날짜 구분선 삽입 판단용 */ +export function isSameDay(a: string | Date, b: string | Date): boolean { + const dateA = toValidDate(a) + const dateB = toValidDate(b) + if (!dateA || !dateB) return false + return startOfDay(dateA) === startOfDay(dateB) +} + +/** + * 채팅 목록 행의 상대 시각. + * 오늘은 시각, 어제는 '어제', 올해는 'M월 D일', 그 이전은 'YYYY. M. D.' + */ +export function formatChatListTime( + value: string | Date, + now: Date = new Date() +): string { + const date = toValidDate(value) + if (!date) return '' + + const dayDiff = Math.round( + (startOfDay(now) - startOfDay(date)) / (24 * 60 * 60 * 1000) + ) + + if (dayDiff <= 0) return formatMessageTime(date) + if (dayDiff === 1) return '어제' + if (date.getFullYear() === now.getFullYear()) { + return `${date.getMonth() + 1}월 ${date.getDate()}일` + } + return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}.` +} diff --git a/src/features/chat/lib/chatTimeline.ts b/src/features/chat/lib/chatTimeline.ts new file mode 100644 index 0000000..4ab8643 --- /dev/null +++ b/src/features/chat/lib/chatTimeline.ts @@ -0,0 +1,79 @@ +import type { + ChatMessage, + ChatSegment, + ChatTimelineEntry, +} from '@/features/chat/types/chat' +import { formatDateDivider, isSameDay } from '@/features/chat/lib/chatTime' + +/** + * 메시지 배열(오래된 → 최신)을 날짜 구분선이 섞인 렌더 목록으로 변환합니다. + * 전체 채팅에서는 발신자가 바뀌는 첫 메시지에만 이름·아바타를 노출합니다. + */ +export function buildChatTimeline( + messages: ChatMessage[], + segment: ChatSegment, + now: Date = new Date() +): ChatTimelineEntry[] { + const entries: ChatTimelineEntry[] = [] + + messages.forEach((message, index) => { + const previous = index > 0 ? messages[index - 1] : undefined + + if (!previous || !isSameDay(previous.createdAt, message.createdAt)) { + entries.push({ + kind: 'date', + key: `date-${message.createdAt}-${message.id}`, + label: formatDateDivider(message.createdAt, now), + }) + } + + const isNewSenderBlock = + !previous || + previous.senderId !== message.senderId || + previous.senderScope !== message.senderScope || + !isSameDay(previous.createdAt, message.createdAt) + + entries.push({ + kind: 'message', + key: message.clientId ?? `message-${message.id}`, + message, + showSenderMeta: + segment === 'group' && !message.isMine && isNewSenderBlock, + }) + }) + + return entries +} + +/** + * 낙관적 메시지와 서버 메시지를 합칩니다. + * 서버 echo 가 도착하면 같은 내용의 pending 메시지를 제거해 중복을 막습니다. + */ +export function mergeChatMessages( + serverMessages: ChatMessage[], + pendingMessages: ChatMessage[] +): ChatMessage[] { + if (pendingMessages.length === 0) return serverMessages + + const serverSignatures = new Set( + serverMessages + .filter(message => message.isMine) + .map(message => message.content) + ) + + const remainingPending = pendingMessages.filter( + pending => + pending.status === 'failed' || !serverSignatures.has(pending.content) + ) + + return [...serverMessages, ...remainingPending] +} + +/** 오래된 → 최신 정렬. 동일 시각이면 id 오름차순 */ +export function sortMessagesAscending(messages: ChatMessage[]): ChatMessage[] { + return [...messages].sort((a, b) => { + const diff = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + return diff !== 0 ? diff : a.id - b.id + }) +} diff --git a/src/features/chat/lib/messageDraft.ts b/src/features/chat/lib/messageDraft.ts new file mode 100644 index 0000000..6bd1022 --- /dev/null +++ b/src/features/chat/lib/messageDraft.ts @@ -0,0 +1,18 @@ +import { CHAT_MESSAGE_MAX_LENGTH } from '@/features/chat/types/chat' + +/** 상한을 넘긴 입력은 잘라내 붙여넣기로도 1000자를 넘지 않게 합니다 */ +export function clampMessageDraft(value: string): string { + return value.length > CHAT_MESSAGE_MAX_LENGTH + ? value.slice(0, CHAT_MESSAGE_MAX_LENGTH) + : value +} + +/** 공백만 입력한 경우 전송 비활성 */ +export function canSendMessage(draft: string): boolean { + const trimmed = draft.trim() + return trimmed.length > 0 && trimmed.length <= CHAT_MESSAGE_MAX_LENGTH +} + +export function isMessageDraftAtLimit(draft: string): boolean { + return draft.length >= CHAT_MESSAGE_MAX_LENGTH +} diff --git a/src/features/chat/lib/segmentPreference.ts b/src/features/chat/lib/segmentPreference.ts new file mode 100644 index 0000000..21ca1e6 --- /dev/null +++ b/src/features/chat/lib/segmentPreference.ts @@ -0,0 +1,24 @@ +import type { ChatSegment } from '@/features/chat/types/chat' + +const STORAGE_KEY = 'alter:chat:last-segment' + +/** 채팅 탭 진입 시 개인 채팅이 기본이고, 마지막 선택 세그먼트를 기억합니다 */ +export function readLastChatSegment(): ChatSegment { + if (typeof window === 'undefined') return 'personal' + try { + return window.localStorage.getItem(STORAGE_KEY) === 'group' + ? 'group' + : 'personal' + } catch { + return 'personal' + } +} + +export function writeLastChatSegment(segment: ChatSegment): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(STORAGE_KEY, segment) + } catch { + // 저장 실패는 기본값(개인)으로 동작하면 되므로 무시합니다 + } +} diff --git a/src/features/chat/lib/stompDestinations.ts b/src/features/chat/lib/stompDestinations.ts new file mode 100644 index 0000000..8a07ec9 --- /dev/null +++ b/src/features/chat/lib/stompDestinations.ts @@ -0,0 +1,15 @@ +import { getAuthApiBasePath } from '@/shared/lib/authApiPath' +import type { ChatApiScope } from '@/features/chat/api/chatRoom' + +/** 구독 — /sub/chat.{roomId} */ +export function chatSubscribeDestination(roomId: number): string { + return `/sub/chat.${roomId}` +} + +/** 발행 — /pub/{app|manager}/send.{roomId} */ +export function chatPublishDestination( + scope: ChatApiScope, + roomId: number +): string { + return `/pub/${getAuthApiBasePath(scope)}/send.${roomId}` +} diff --git a/src/features/chat/mock/groupChatMockStore.ts b/src/features/chat/mock/groupChatMockStore.ts new file mode 100644 index 0000000..88ec61d --- /dev/null +++ b/src/features/chat/mock/groupChatMockStore.ts @@ -0,0 +1,180 @@ +import { create } from 'zustand' +import type { + ChatMessage, + ChatParticipantScope, + ChatRoomListItem, +} from '@/features/chat/types/chat' + +/** + * 전체 채팅(업장 단위 단체방) 목업. + * + * 스펙 기준으로 백엔드에 group room + 발신자별 메시지 모델이 아직 없습니다. + * 디자인·인터랙션 검증용 인메모리 스토어이며, API가 준비되면 이 파일과 + * `useGroupChatMock*` 훅만 실제 쿼리로 교체하면 됩니다. + */ + +const MY_SENDER_ID = -1 + +function minutesAgo(minutes: number): string { + return new Date(Date.now() - minutes * 60_000).toISOString() +} + +interface MockGroupRoomSeed { + id: number + workspaceName: string + memberCount: number + messages: Array<{ + senderId: number + senderScope: ChatParticipantScope + senderName: string + content: string + minutesAgo: number + isMine?: boolean + }> +} + +const SEEDS: MockGroupRoomSeed[] = [ + { + id: 9001, + workspaceName: '알터 강남점', + memberCount: 7, + messages: [ + { + senderId: 101, + senderScope: 'MANAGER', + senderName: '최민석 점주님', + content: '근무표 확정해서 공유드려요', + minutesAgo: 1500, + }, + { + senderId: MY_SENDER_ID, + senderScope: 'USER', + senderName: '', + content: '확인했습니다!', + minutesAgo: 1496, + isMine: true, + }, + { + senderId: 102, + senderScope: 'USER', + senderName: '이서준', + content: '이번 주 금요일 오픈 담당 누구인가요?', + minutesAgo: 180, + }, + { + senderId: 101, + senderScope: 'MANAGER', + senderName: '최민석 점주님', + content: '서준님이 오픈, 지원님이 미들입니다.', + minutesAgo: 174, + }, + ], + }, + { + id: 9002, + workspaceName: '알터 성수점', + memberCount: 4, + messages: [ + { + senderId: 201, + senderScope: 'MANAGER', + senderName: '박서연 점주님', + content: '다음 주 재고 조사 일정 공유합니다.', + minutesAgo: 40, + }, + ], + }, +] + +function seedMessages(seed: MockGroupRoomSeed): ChatMessage[] { + return seed.messages.map((message, index) => ({ + id: seed.id * 100 + index, + senderId: message.senderId, + senderScope: message.senderScope, + senderName: message.senderName, + senderProfileImageUrl: null, + content: message.content, + createdAt: minutesAgo(message.minutesAgo), + isMine: message.isMine ?? false, + status: 'sent' as const, + messageType: 'NORMAL' as const, + attachments: [], + })) +} + +interface GroupChatMockState { + messagesByRoomId: Record + unreadByRoomId: Record + sendMessage: (roomId: number, content: string) => void + markRead: (roomId: number) => void +} + +export const useGroupChatMockStore = create(set => ({ + messagesByRoomId: Object.fromEntries( + SEEDS.map(seed => [seed.id, seedMessages(seed)]) + ), + unreadByRoomId: { 9001: 1, 9002: 0 }, + sendMessage: (roomId, content) => + set(state => { + const existing = state.messagesByRoomId[roomId] ?? [] + const nextMessage: ChatMessage = { + id: Date.now(), + senderId: MY_SENDER_ID, + senderScope: 'USER', + senderName: '', + senderProfileImageUrl: null, + content, + createdAt: new Date().toISOString(), + isMine: true, + status: 'sent', + messageType: 'NORMAL', + attachments: [], + } + return { + messagesByRoomId: { + ...state.messagesByRoomId, + [roomId]: [...existing, nextMessage], + }, + } + }), + markRead: roomId => + set(state => + state.unreadByRoomId[roomId] === 0 + ? state + : { unreadByRoomId: { ...state.unreadByRoomId, [roomId]: 0 } } + ), +})) + +/** + * 스토어 스냅샷으로 목록 행을 만듭니다. + * zustand 셀렉터로 쓰지 마세요 — 매번 새 배열을 만들어 무한 렌더가 납니다. + */ +export function buildGroupChatRooms( + messagesByRoomId: Record, + unreadByRoomId: Record +): ChatRoomListItem[] { + return SEEDS.map(seed => { + const messages = messagesByRoomId[seed.id] ?? [] + const latest = messages[messages.length - 1] + return { + id: seed.id, + segment: 'group' as const, + title: seed.workspaceName, + profileImageUrl: null, + latestMessage: latest?.content ?? '', + updatedAt: latest?.createdAt ?? new Date(0).toISOString(), + unreadCount: unreadByRoomId[seed.id] ?? 0, + memberCount: seed.memberCount, + } + }).sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ) +} + +export function isGroupChatRoomId(roomId: number): boolean { + return SEEDS.some(seed => seed.id === roomId) +} + +export function findGroupChatRoomSeed(roomId: number) { + return SEEDS.find(seed => seed.id === roomId) +} diff --git a/src/features/chat/types/chat.ts b/src/features/chat/types/chat.ts new file mode 100644 index 0000000..2532839 --- /dev/null +++ b/src/features/chat/types/chat.ts @@ -0,0 +1,111 @@ +import type { + ChatAttachmentDto, + ChatMessageType, + ChatParticipantScope, +} from '@/features/chat/types/dto' + +export type { ChatParticipantScope, ChatMessageType } + +/** 이미지 첨부 — 수신 표시용. 업로드 엔드포인트가 없어 아직 전송은 못 합니다 */ +export type ChatAttachment = ChatAttachmentDto + +/** 채팅 탭 세그먼트 — 개인(1:1) · 전체(소속 업장 단위 단체방) */ +export type ChatSegment = 'personal' | 'group' + +export const CHAT_SEGMENTS: ChatSegment[] = ['personal', 'group'] + +export const CHAT_SEGMENT_LABEL: Record = { + personal: '개인 채팅', + group: '전체 채팅', +} + +/** 텍스트 전용 · 최대 1000자 */ +export const CHAT_MESSAGE_MAX_LENGTH = 1000 + +/** 채팅 목록 행 */ +export interface ChatRoomListItem { + id: number + segment: ChatSegment + /** 개인=상대 이름(USER→점주, MANAGER→알바생) · 전체=업장 이름 */ + title: string + profileImageUrl: string | null + latestMessage: string + /** 정렬·상대 시각 표기 기준 */ + updatedAt: string + unreadCount: number + opponentId?: number + opponentScope?: ChatParticipantScope + /** 전체 채팅방의 멤버 수 — 개인 채팅에서는 undefined */ + memberCount?: number +} + +/** 딥링크로 방에 바로 진입해 목록 캐시가 없을 때 헤더·발신자 판별을 채웁니다 */ +export interface ChatRoomDetail { + id: number + title: string + profileImageUrl: string | null + opponentId: number + opponentScope: ChatParticipantScope +} + +/** 낙관적 전송 상태 — STOMP echo 수신 전까지 pending */ +export type ChatMessageStatus = 'sent' | 'pending' | 'failed' + +/** 채팅방 메시지 */ +export interface ChatMessage { + id: number + /** 낙관적 메시지 식별용 — 서버 echo 로 대체되면 제거됩니다 */ + clientId?: string + senderId: number + senderScope: ChatParticipantScope + senderName: string + senderProfileImageUrl: string | null + /** 이미지 전용 메시지는 빈 문자열 */ + content: string + createdAt: string + isMine: boolean + status: ChatMessageStatus + messageType: ChatMessageType + attachments: ChatAttachment[] + /** 이 메시지를 아직 읽지 않은 멤버 수 — 서버가 주지 않으면 undefined */ + unreadCount?: number +} + +/** 날짜 구분선을 포함한 렌더 단위 */ +export type ChatTimelineEntry = + | { kind: 'date'; key: string; label: string } + | { + kind: 'message' + key: string + message: ChatMessage + showSenderMeta: boolean + } + +/** 채팅방 헤더·전송 경로 산출에 필요한 최소 정보 */ +export interface ChatRoomContext { + id: number + segment: ChatSegment + title: string + memberCount?: number +} + +/** 새 채팅 상대 후보 */ +export interface ChatContact { + /** `${scope}:${id}` — 동료·점주 목록을 합칠 때 키 충돌 방지 */ + key: string + id: number + scope: ChatParticipantScope + name: string + profileImageUrl: string | null + workspaceName: string + /** 이미 방이 있으면 해당 roomId — '대화중' 표시 및 기존 방 재사용 */ + existingRoomId?: number +} + +/** STOMP 연결 상태 — 안내 문구는 중립 톤(text70)으로 노출 */ +export type ChatConnectionState = + | 'idle' + | 'connecting' + | 'connected' + | 'reconnecting' + | 'disconnected' diff --git a/src/features/chat/types/dto.ts b/src/features/chat/types/dto.ts new file mode 100644 index 0000000..a3a95df --- /dev/null +++ b/src/features/chat/types/dto.ts @@ -0,0 +1,96 @@ +/** + * 채팅 서버 응답 shape — USER(`/app/chat/*`) · MANAGER(`/manager/chat/*`) 공통. + * 스펙에 아직 없는 필드(프로필 이미지·방별 미읽음·발신자 이름)는 optional 로 두고 클라이언트에서 폴백합니다. + */ + +/** 클라이언트 도메인 표현 — 서버의 APP 을 USER 로 정규화해 씁니다 */ +export type ChatParticipantScope = 'USER' | 'MANAGER' + +/** 서버 enum — 알바생은 USER 가 아니라 APP */ +export type ChatServerScope = 'APP' | 'MANAGER' + +/** + * scope 직렬화가 엔드포인트마다 다릅니다. + * 방 목록은 평문 `"APP"`, 방 상세·메시지는 `{ value, description }` 객체로 내려옵니다. + */ +export type ChatScopeDto = string | { value: string; description?: string } + +export type ChatMessageType = 'NORMAL' | 'NOTICE' + +export interface ChatAttachmentDto { + fileId: string + url: string +} + +/** GET /{app|manager}/chat/rooms */ +export interface ChatRoomListItemDto { + id: number + opponentId: number + opponentScope: ChatScopeDto + opponentName: string + /** API 미제공 — 없으면 Avatar 기본 프로필로 폴백 */ + opponentProfileImageUrl?: string | null + latestMessageContent: string | null + /** API 미제공 — 없으면 0으로 간주 (목록 뱃지·Docbar 뱃지 소스) */ + unreadCount?: number + createdAt: string + updatedAt: string +} + +/** GET /{app|manager}/chat/rooms/{chatRoomId} — 딥링크 진입 시 헤더 정보 */ +export interface ChatRoomDetailDto { + id: number + opponentId: number + opponentScope: ChatScopeDto + opponentName: string + opponentProfileImageUrl?: string | null + createdAt: string + updatedAt: string +} + +/** GET /{app|manager}/chat/rooms/{id}/messages · 구독 /sub/chat.{id} 페이로드 */ +export interface ChatMessageDto { + id: number + chatRoomId?: number + senderId: number + senderScope: ChatScopeDto + type?: ChatMessageType + /** 이미지 전용 메시지는 null */ + content: string | null + createdAt: string + isMine?: boolean + /** 이 메시지를 아직 읽지 않은 멤버 수 */ + unreadCount?: number + attachments?: ChatAttachmentDto[] + /** API 미제공 — 단체방 발신자 표기에 필요 */ + senderName?: string + senderProfileImageUrl?: string | null +} + +/** POST /{app|manager}/chat/rooms */ +export interface CreateChatRoomRequest { + opponentUserId: number + opponentScope: ChatServerScope +} + +export interface CreateChatRoomResponseDto { + chatRoomId: number +} + +/** POST /{app|manager}/chat/rooms/{id}/read */ +export interface MarkChatRoomReadRequest { + lastReadMessageId: number +} + +/** GET /{app|manager}/chat/workspace/{workspaceId}/room */ +export interface WorkspaceGroupChatRoomDto { + chatRoomId: number +} + +/** 발행 /pub/{app|manager}/send.{id} */ +export interface SendChatMessagePayload { + content?: string + type?: ChatMessageType + /** 최대 10개 */ + fileIds?: string[] +} diff --git a/src/shared/lib/stompConnection.ts b/src/shared/lib/stompConnection.ts new file mode 100644 index 0000000..de4bf23 --- /dev/null +++ b/src/shared/lib/stompConnection.ts @@ -0,0 +1,159 @@ +import { Client, type StompSubscription } from '@stomp/stompjs' + +import { API_CONFIG } from './apiConfig' +import { useAuthStore } from '../stores/useAuthStore' + +export type StompStatus = + | 'idle' + | 'connecting' + | 'connected' + | 'reconnecting' + | 'disconnected' + +const RECONNECT_DELAY = 3000 +const HEARTBEAT_INTERVAL = 10_000 + +/** `ws(s)://` 절대 주소는 그대로, 그 외에는 현재 오리진 기준으로 해석합니다 */ +export function resolveBrokerUrl(configured: string): string { + if (/^wss?:\/\//.test(configured)) return configured + if (typeof window === 'undefined') return configured + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const path = configured.startsWith('/') ? configured : `/${configured}` + return `${protocol}//${window.location.host}${path}` +} + +interface Subscriber { + id: number + destination: string + onMessage: (body: string) => void + subscription: StompSubscription | null +} + +/** + * 앱 전역 STOMP 연결. + * + * - 화면(훅)들이 acquire/release 로 참조를 잡고, 참조가 0이 되면 연결을 끊습니다. + * - 구독 요청은 매니저가 보관하다가 연결·재연결 시점에 실제 구독으로 붙입니다. + * (stompjs 는 미연결 상태의 subscribe 를 큐잉하지 않습니다) + */ +class StompConnectionManager { + private client: Client | null = null + private refCount = 0 + private status: StompStatus = 'idle' + private statusListeners = new Set<(status: StompStatus) => void>() + private subscribers = new Map() + private subscriberSeq = 0 + private hasConnectedOnce = false + + /** useSyncExternalStore 에 그대로 넘길 수 있도록 바인딩된 형태로 노출합니다 */ + getStatus = (): StompStatus => this.status + + onStatusChange = (listener: (status: StompStatus) => void): (() => void) => { + this.statusListeners.add(listener) + return () => { + this.statusListeners.delete(listener) + } + } + + private setStatus(next: StompStatus) { + if (this.status === next) return + this.status = next + this.statusListeners.forEach(listener => listener(next)) + } + + private createClient(): Client { + const client = new Client({ + brokerURL: resolveBrokerUrl(API_CONFIG.WS_URL), + reconnectDelay: RECONNECT_DELAY, + heartbeatIncoming: HEARTBEAT_INTERVAL, + heartbeatOutgoing: HEARTBEAT_INTERVAL, + }) + + // 토큰이 갱신될 수 있어 매 연결 시도마다 헤더를 다시 만듭니다 + client.beforeConnect = () => { + const token = useAuthStore.getState().token + client.connectHeaders = token ? { Authorization: `Bearer ${token}` } : {} + this.setStatus(this.hasConnectedOnce ? 'reconnecting' : 'connecting') + } + + client.onConnect = () => { + this.hasConnectedOnce = true + this.setStatus('connected') + this.subscribers.forEach(subscriber => this.attach(subscriber)) + } + + client.onWebSocketClose = () => { + this.subscribers.forEach(subscriber => { + subscriber.subscription = null + }) + // 참조가 남아 있으면 stompjs 가 자동 재연결을 시도합니다 + this.setStatus(this.refCount > 0 ? 'reconnecting' : 'disconnected') + } + + client.onStompError = () => { + this.setStatus('reconnecting') + } + + return client + } + + private attach(subscriber: Subscriber) { + if (!this.client?.connected || subscriber.subscription) return + subscriber.subscription = this.client.subscribe( + subscriber.destination, + frame => subscriber.onMessage(frame.body) + ) + } + + acquire(): void { + this.refCount += 1 + if (!this.client) { + this.client = this.createClient() + } + if (!this.client.active) { + this.client.activate() + } + } + + release(): void { + this.refCount = Math.max(0, this.refCount - 1) + if (this.refCount > 0) return + + const client = this.client + this.client = null + this.subscribers.clear() + this.hasConnectedOnce = false + this.setStatus('idle') + void client?.deactivate() + } + + subscribe( + destination: string, + onMessage: (body: string) => void + ): () => void { + this.subscriberSeq += 1 + const subscriber: Subscriber = { + id: this.subscriberSeq, + destination, + onMessage, + subscription: null, + } + this.subscribers.set(subscriber.id, subscriber) + this.attach(subscriber) + + return () => { + subscriber.subscription?.unsubscribe() + this.subscribers.delete(subscriber.id) + } + } + + /** 연결 전이면 false — 호출자가 전송 실패로 처리합니다 */ + publish(destination: string, body: unknown): boolean { + if (!this.client?.connected) return false + this.client.publish({ destination, body: JSON.stringify(body) }) + return true + } +} + +export const stompConnection = new StompConnectionManager() From d66a26fb1402bca09ca516cfbd965cf00c2d9030 Mon Sep 17 00:00:00 2001 From: Dohyeon Date: Wed, 19 Aug 2026 13:28:13 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=B1=84=ED=8C=85=EB=B0=A9=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개인/전체 세그먼트 목록, 실시간 채팅방, 새 채팅 상대 선택, 전체 채팅 목업과 첨부 트레이(디자인만)를 추가한다. Co-authored-by: Cursor --- src/features/chat/index.ts | 48 +++++ src/features/chat/ui/AttachmentTray.tsx | 43 ++++ src/features/chat/ui/ChatBubble.tsx | 101 ++++++++++ src/features/chat/ui/ChatConnectionBanner.tsx | 57 ++++++ src/features/chat/ui/ChatDateDivider.tsx | 13 ++ src/features/chat/ui/ChatRoomListItem.tsx | 149 ++++++++++++++ src/features/chat/ui/ChatRoomListStates.tsx | 63 ++++++ src/features/chat/ui/ChatSegmentTab.tsx | 50 +++++ src/features/chat/ui/ContactPicker.tsx | 47 +++++ src/features/chat/ui/MessageInput.tsx | 134 +++++++++++++ src/features/chat/ui/NewChatFab.tsx | 26 +++ src/features/chat/ui/NewChatSheet.tsx | 101 ++++++++++ src/pages/chat/room/index.tsx | 189 ++++++++++++++++++ src/pages/chat/rooms/index.tsx | 144 +++++++++++++ 14 files changed, 1165 insertions(+) create mode 100644 src/features/chat/index.ts create mode 100644 src/features/chat/ui/AttachmentTray.tsx create mode 100644 src/features/chat/ui/ChatBubble.tsx create mode 100644 src/features/chat/ui/ChatConnectionBanner.tsx create mode 100644 src/features/chat/ui/ChatDateDivider.tsx create mode 100644 src/features/chat/ui/ChatRoomListItem.tsx create mode 100644 src/features/chat/ui/ChatRoomListStates.tsx create mode 100644 src/features/chat/ui/ChatSegmentTab.tsx create mode 100644 src/features/chat/ui/ContactPicker.tsx create mode 100644 src/features/chat/ui/MessageInput.tsx create mode 100644 src/features/chat/ui/NewChatFab.tsx create mode 100644 src/features/chat/ui/NewChatSheet.tsx create mode 100644 src/pages/chat/room/index.tsx create mode 100644 src/pages/chat/rooms/index.tsx diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts new file mode 100644 index 0000000..8947045 --- /dev/null +++ b/src/features/chat/index.ts @@ -0,0 +1,48 @@ +export { + createChatRoom, + fetchChatMessages, + fetchChatRooms, + markChatRoomRead, +} from './api/chatRoom' +export { fetchChatContacts } from './api/chatContacts' + +export { useChatListViewModel } from './hooks/useChatListViewModel' +export { useChatRoomViewModel } from './hooks/useChatRoomViewModel' +export { useNewChatViewModel } from './hooks/useNewChatViewModel' +export { useChatRoomsQuery } from './hooks/query/useChatRoomsQuery' +export { useChatMessagesQuery } from './hooks/query/useChatMessagesQuery' +export { useChatContactsQuery } from './hooks/query/useChatContactsQuery' +export { useChatStomp } from './hooks/useChatStomp' + +export { ChatBubble } from './ui/ChatBubble' +export { ChatConnectionBanner } from './ui/ChatConnectionBanner' +export { ChatDateDivider } from './ui/ChatDateDivider' +export { ChatRoomRow, SwipeableChatRoomItem } from './ui/ChatRoomListItem' +export { ChatSegmentTab } from './ui/ChatSegmentTab' +export { ContactPickerRow } from './ui/ContactPicker' +export { MessageInput } from './ui/MessageInput' +export { NewChatFab } from './ui/NewChatFab' +export { NewChatSheet } from './ui/NewChatSheet' +export { AttachmentTray } from './ui/AttachmentTray' + +export { + CHAT_MESSAGE_MAX_LENGTH, + CHAT_SEGMENTS, + CHAT_SEGMENT_LABEL, +} from './types/chat' +export type { + ChatConnectionState, + ChatContact, + ChatMessage, + ChatMessageStatus, + ChatRoomContext, + ChatRoomListItem, + ChatSegment, + ChatTimelineEntry, +} from './types/chat' +export type { + ChatMessageDto, + ChatParticipantScope, + ChatRoomListItemDto, + CreateChatRoomRequest, +} from './types/dto' diff --git a/src/features/chat/ui/AttachmentTray.tsx b/src/features/chat/ui/AttachmentTray.tsx new file mode 100644 index 0000000..f10fac6 --- /dev/null +++ b/src/features/chat/ui/AttachmentTray.tsx @@ -0,0 +1,43 @@ +import cameraIcon from '@/assets/icons/camera.svg' +import imageIcon from '@/assets/icons/image.svg' + +const ATTACHMENT_ITEMS = [ + { key: 'gallery', label: '사진', icon: imageIcon }, + { key: 'camera', label: '카메라', icon: cameraIcon }, +] as const + +/** + * P1 · 디자인만 — 이미지 메시지는 백엔드 미지원이라 데이터 연결이 없습니다. + * 업로드 API(`targetType: CHAT_MESSAGE`)가 열리면 각 버튼에 핸들러를 붙이면 됩니다. + */ +export function AttachmentTray() { + return ( +

+
+ {ATTACHMENT_ITEMS.map(item => ( + + ))} +
+

+ 사진 전송은 준비 중이에요. +

+
+ ) +} diff --git a/src/features/chat/ui/ChatBubble.tsx b/src/features/chat/ui/ChatBubble.tsx new file mode 100644 index 0000000..adefccf --- /dev/null +++ b/src/features/chat/ui/ChatBubble.tsx @@ -0,0 +1,101 @@ +import { cn } from '@/shared/lib/utils' +import { Avatar } from '@/shared/ui/common/Avatar' +import { formatMessageTime } from '@/features/chat/lib/chatTime' +import type { ChatMessage } from '@/features/chat/types/chat' + +interface ChatBubbleProps { + message: ChatMessage + /** 전체 채팅에서 발신자가 바뀌는 첫 메시지에만 이름·아바타를 노출합니다 */ + showSenderMeta?: boolean + /** 아바타 자리 유지 — 전체 채팅에서 같은 발신자의 연속 메시지 정렬용 */ + reserveAvatarSpace?: boolean +} + +const AVATAR_SIZE = 36 + +export function ChatBubble({ + message, + showSenderMeta = false, + reserveAvatarSpace = false, +}: ChatBubbleProps) { + const timeLabel = formatMessageTime(message.createdAt) + const isPending = message.status === 'pending' + const isFailed = message.status === 'failed' + + const hasAttachments = message.attachments.length > 0 + const isNotice = message.messageType === 'NOTICE' + + const bubble = ( +
+ {isNotice ? ( + 공지 + ) : null} + {hasAttachments + ? message.attachments.map(attachment => ( + 첨부 이미지 + )) + : null} + {message.content ? {message.content} : null} +
+ ) + + const meta = ( + + {isFailed ? '전송 실패' : isPending ? '전송 중' : timeLabel} + + ) + + if (message.isMine) { + return ( +
+ {meta} + {bubble} +
+ ) + } + + return ( +
+ {showSenderMeta ? ( + + ) : reserveAvatarSpace ? ( +
+ ) : null} + +
+ {showSenderMeta && message.senderName ? ( + + {message.senderName} + + ) : null} +
+ {bubble} + {meta} +
+
+
+ ) +} diff --git a/src/features/chat/ui/ChatConnectionBanner.tsx b/src/features/chat/ui/ChatConnectionBanner.tsx new file mode 100644 index 0000000..1fdaa76 --- /dev/null +++ b/src/features/chat/ui/ChatConnectionBanner.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react' +import type { ChatConnectionState } from '@/features/chat/types/chat' + +/** '다시 연결됐어요' 안내를 노출하는 시간(ms) */ +const RECONNECTED_NOTICE_DURATION = 2000 + +function isDown(state: ChatConnectionState): boolean { + return state === 'reconnecting' || state === 'disconnected' +} + +interface ChatConnectionBannerProps { + state: ChatConnectionState +} + +/** 중립 톤(text70) 안내 — 브랜드 그린은 쓰지 않습니다 */ +export function ChatConnectionBanner({ state }: ChatConnectionBannerProps) { + const [notice, setNotice] = useState({ + trackedState: state, + showReconnected: false, + }) + + // 상태 전이는 렌더 중 조정 — 끊김 이후 연결됐을 때만 복구 안내를 띄웁니다 + if (notice.trackedState !== state) { + setNotice({ + trackedState: state, + showReconnected: isDown(notice.trackedState) && state === 'connected', + }) + } + + const { showReconnected } = notice + + useEffect(() => { + if (!showReconnected) return + const timer = window.setTimeout( + () => setNotice(current => ({ ...current, showReconnected: false })), + RECONNECTED_NOTICE_DURATION + ) + return () => window.clearTimeout(timer) + }, [showReconnected]) + + const message = isDown(state) + ? '연결이 끊겼어요 · 다시 연결 중…' + : showReconnected + ? '다시 연결됐어요' + : null + + if (!message) return null + + return ( +
+ {message} +
+ ) +} diff --git a/src/features/chat/ui/ChatDateDivider.tsx b/src/features/chat/ui/ChatDateDivider.tsx new file mode 100644 index 0000000..c06aaed --- /dev/null +++ b/src/features/chat/ui/ChatDateDivider.tsx @@ -0,0 +1,13 @@ +interface ChatDateDividerProps { + label: string +} + +export function ChatDateDivider({ label }: ChatDateDividerProps) { + return ( +
+ + {label} + +
+ ) +} diff --git a/src/features/chat/ui/ChatRoomListItem.tsx b/src/features/chat/ui/ChatRoomListItem.tsx new file mode 100644 index 0000000..eef8db8 --- /dev/null +++ b/src/features/chat/ui/ChatRoomListItem.tsx @@ -0,0 +1,149 @@ +import { useRef, useState, type PointerEventHandler } from 'react' +import TrashIcon from '@/assets/icons/social/trash.svg' +import { cn } from '@/shared/lib/utils' +import { Avatar } from '@/shared/ui/common/Avatar' +import { UnreadBadge } from '@/shared/ui/common/UnreadBadge' +import { formatChatListTime } from '@/features/chat/lib/chatTime' +import type { ChatRoomListItem as ChatRoomListItemModel } from '@/features/chat/types/chat' + +const ACTION_WIDTH = 72 +const OPEN_THRESHOLD = ACTION_WIDTH * 0.45 +/** 세로 스크롤과 구분하기 위한 가로 이동 최소값 */ +const HORIZONTAL_INTENT = 8 + +interface ChatRoomRowProps { + room: ChatRoomListItemModel + onClick?: () => void +} + +export function ChatRoomRow({ room, onClick }: ChatRoomRowProps) { + const hasUnread = room.unreadCount > 0 + + return ( + + ) +} + +interface SwipeableChatRoomItemProps extends ChatRoomRowProps { + /** P1 — 스와이프 삭제는 백엔드 지원 후 연결합니다 */ + onDelete?: () => void +} + +export function SwipeableChatRoomItem({ + room, + onClick, + onDelete, +}: SwipeableChatRoomItemProps) { + const [translateX, setTranslateX] = useState(0) + const [isDragging, setIsDragging] = useState(false) + + const pointerIdRef = useRef(null) + const startXRef = useRef(0) + const startTranslateXRef = useRef(0) + const didSwipeRef = useRef(false) + + const handlePointerDown: PointerEventHandler = event => { + if (event.button !== 0) return + pointerIdRef.current = event.pointerId + startXRef.current = event.clientX + startTranslateXRef.current = translateX + didSwipeRef.current = false + setIsDragging(true) + event.currentTarget.setPointerCapture(event.pointerId) + } + + const handlePointerMove: PointerEventHandler = event => { + if (!isDragging || pointerIdRef.current !== event.pointerId) return + + const deltaX = event.clientX - startXRef.current + if (Math.abs(deltaX) > HORIZONTAL_INTENT) { + didSwipeRef.current = true + } + setTranslateX( + Math.min(0, Math.max(-ACTION_WIDTH, startTranslateXRef.current + deltaX)) + ) + } + + const finishDrag = (pointerId: number) => { + if (pointerIdRef.current !== pointerId) return + + pointerIdRef.current = null + setIsDragging(false) + setTranslateX(prev => (Math.abs(prev) > OPEN_THRESHOLD ? -ACTION_WIDTH : 0)) + } + + /** 스와이프 제스처가 방 진입으로 오인되지 않도록 클릭을 걸러냅니다 */ + const handleRowClick = () => { + if (didSwipeRef.current || translateX !== 0) { + setTranslateX(0) + return + } + onClick?.() + } + + return ( +
+
+ +
+ +
finishDrag(event.pointerId)} + onPointerCancel={event => finishDrag(event.pointerId)} + style={{ + touchAction: 'pan-y', + transform: `translateX(${translateX}px)`, + transition: isDragging ? 'none' : 'transform 180ms ease-out', + }} + > + +
+
+ ) +} diff --git a/src/features/chat/ui/ChatRoomListStates.tsx b/src/features/chat/ui/ChatRoomListStates.tsx new file mode 100644 index 0000000..022c9e7 --- /dev/null +++ b/src/features/chat/ui/ChatRoomListStates.tsx @@ -0,0 +1,63 @@ +import { Skeleton } from '@/shared/ui/common/Skeleton' + +const SKELETON_ROWS = 6 + +export function ChatRoomListSkeleton() { + return ( +
+ {Array.from({ length: SKELETON_ROWS }, (_, index) => ( +
+ +
+
+ + +
+ +
+
+ ))} +
+ ) +} + +interface ChatRoomListEmptyProps { + title: string + description: string +} + +export function ChatRoomListEmpty({ + title, + description, +}: ChatRoomListEmptyProps) { + return ( +
+

{title}

+

{description}

+
+ ) +} + +interface ChatRoomListErrorProps { + onRetry: () => void +} + +export function ChatRoomListError({ onRetry }: ChatRoomListErrorProps) { + return ( +
+

+ 목록을 불러오지 못했어요. +

+ +
+ ) +} diff --git a/src/features/chat/ui/ChatSegmentTab.tsx b/src/features/chat/ui/ChatSegmentTab.tsx new file mode 100644 index 0000000..3c08aec --- /dev/null +++ b/src/features/chat/ui/ChatSegmentTab.tsx @@ -0,0 +1,50 @@ +import { cn } from '@/shared/lib/utils' +import { UnreadBadge } from '@/shared/ui/common/UnreadBadge' +import { + CHAT_SEGMENTS, + CHAT_SEGMENT_LABEL, + type ChatSegment, +} from '@/features/chat/types/chat' + +interface ChatSegmentTabProps { + activeSegment: ChatSegment + onSegmentChange: (segment: ChatSegment) => void + /** 세그먼트 라벨 옆 미읽음 합계 */ + unreadCountBySegment?: Partial> +} + +/** 근무표 ScheduleTabBar 의 밑줄 활성 패턴을 계승합니다 */ +export function ChatSegmentTab({ + activeSegment, + onSegmentChange, + unreadCountBySegment, +}: ChatSegmentTabProps) { + return ( +
+ {CHAT_SEGMENTS.map(segment => { + const isActive = activeSegment === segment + return ( + + ) + })} +
+ ) +} diff --git a/src/features/chat/ui/ContactPicker.tsx b/src/features/chat/ui/ContactPicker.tsx new file mode 100644 index 0000000..1eac08e --- /dev/null +++ b/src/features/chat/ui/ContactPicker.tsx @@ -0,0 +1,47 @@ +import MessageIcon from '@/assets/icons/doc/Message.svg?react' +import { Avatar } from '@/shared/ui/common/Avatar' +import type { ChatContact } from '@/features/chat/types/chat' + +interface ContactPickerRowProps { + contact: ChatContact + onSelect: () => void + disabled?: boolean +} + +export function ContactPickerRow({ + contact, + onSelect, + disabled = false, +}: ContactPickerRowProps) { + return ( + + ) +} diff --git a/src/features/chat/ui/MessageInput.tsx b/src/features/chat/ui/MessageInput.tsx new file mode 100644 index 0000000..344c9f0 --- /dev/null +++ b/src/features/chat/ui/MessageInput.tsx @@ -0,0 +1,134 @@ +import { useRef, type ChangeEvent, type KeyboardEvent } from 'react' +import SendIcon from '@/assets/icons/socialvector.svg' +import { cn } from '@/shared/lib/utils' +import { + canSendMessage, + clampMessageDraft, + isMessageDraftAtLimit, +} from '@/features/chat/lib/messageDraft' +import { CHAT_MESSAGE_MAX_LENGTH } from '@/features/chat/types/chat' + +interface MessageInputProps { + value: string + onChange: (value: string) => void + onSend: () => void + /** + 버튼 — 첨부 트레이 토글 (P1, 디자인만) */ + onToggleAttachment?: () => void + isAttachmentOpen?: boolean + disabled?: boolean +} + +const MAX_TEXTAREA_ROWS_HEIGHT = 96 + +export function MessageInput({ + value, + onChange, + onSend, + onToggleAttachment, + isAttachmentOpen = false, + disabled = false, +}: MessageInputProps) { + const textareaRef = useRef(null) + const canSend = !disabled && canSendMessage(value) + const atLimit = isMessageDraftAtLimit(value) + + const resize = () => { + const el = textareaRef.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, MAX_TEXTAREA_ROWS_HEIGHT)}px` + } + + const handleChange = (event: ChangeEvent) => { + onChange(clampMessageDraft(event.target.value)) + resize() + } + + const handleKeyDown = (event: KeyboardEvent) => { + // Shift+Enter 는 줄바꿈, Enter 단독은 전송 + if ( + event.key !== 'Enter' || + event.shiftKey || + event.nativeEvent.isComposing + ) + return + event.preventDefault() + if (canSend) onSend() + } + + const handleSendClick = () => { + if (!canSend) return + onSend() + const el = textareaRef.current + if (el) el.style.height = 'auto' + } + + return ( +
+
+ + +
+