diff --git a/package-lock.json b/package-lock.json index c740b9db..cc538164 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 5b1f3fe3..d25641e0 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/app/App.tsx b/src/app/App.tsx index c64679f7..cd695883 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -9,8 +9,8 @@ import { import { ManagerHomePage } from '@/pages/manager/home' import { ManagerWorkerSchedulePage } from '@/pages/manager/worker-schedule' import { ManagerWorkerScheduleLegacyEntryRedirect } from '@/pages/manager/worker-schedule/LegacyEntryRedirect' -import { SocialPage } from '@/pages/manager/social' -import { SocialChatPage } from '@/pages/manager/social-chat' +import { ChatRoomsPage } from '@/pages/chat/rooms' +import { ChatRoomPage } from '@/pages/chat/room' import { LoginPage } from '@/pages/login' import { FindPasswordPage } from '@/pages/find-password' import { KakaoCallbackPage } from '@/pages/oauth/KakaoCallbackPage' @@ -169,6 +169,17 @@ export function App() { path={ROUTES.MANAGER.WORKER_INVITE} element={} /> + {/* 채팅방은 하단 입력바를 쓰므로 Docbar 없이 표시합니다 */} + } /> + {/* 구 사장님 전용 채팅 경로 — 공용 채팅으로 통합 */} + } + /> + } + /> } @@ -253,11 +264,7 @@ export function App() { } /> - } /> - } - /> + } /> + + diff --git a/src/features/chat/api/chatContacts.ts b/src/features/chat/api/chatContacts.ts new file mode 100644 index 00000000..5c60f76e --- /dev/null +++ b/src/features/chat/api/chatContacts.ts @@ -0,0 +1,150 @@ +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 + +/** 재직 중인 근무자 — 매니저 홈 목록(`useManagerHomeViewModel`)과 같은 값을 씁니다 */ +const WORKER_STATUS_ACTIVE = 'ACTIVATED' + +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, + } +} + +/** + * 업장별 조회를 병렬로 돌리되 일부 실패는 건너뜁니다. + * `Promise.all` 이면 업장 하나가 4xx 를 뱉을 때 연락처 목록 전체가 빈 화면이 됩니다. + */ +async function settleWorkspaceContacts( + tasks: Promise[] +): Promise { + const results = await Promise.allSettled(tasks) + const succeeded = results.filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' + ) + + // 전부 실패했다면 진짜 장애이므로 첫 에러를 그대로 올려 에러 화면을 띄웁니다 + if (succeeded.length === 0 && results.length > 0) { + throw (results[0] as PromiseRejectedResult).reason + } + + return succeeded.flatMap(result => result.value) +} + +/** 중복 인물(여러 업장에 함께 근무)은 첫 업장 기준으로 한 번만 노출합니다 */ +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 settleWorkspaceContacts( + 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) +} + +async function fetchManagerContacts(): Promise { + const workspacesResponse = await axiosInstance.get< + CommonApiResponse + >('/manager/workspaces') + + const workspaces = workspacesResponse.data.data + + const perWorkspace = await settleWorkspaceContacts( + workspaces.map(async workspace => { + const workers = await axiosInstance.get< + CommonApiResponse> + >(`/manager/workspaces/${workspace.id}/workers`, { + // 퇴사자 제외 — 서버 enum 은 EMPLOYED 가 아니라 ACTIVATED 입니다 + params: { pageSize: MEMBER_PAGE_SIZE, status: WORKER_STATUS_ACTIVE }, + }) + + return workers.data.data.data.map(item => + toContact(item.user, 'USER', workspace.businessName) + ) + }) + ) + + return dedupeContacts(perWorkspace) +} + +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 00000000..cf9f3a21 --- /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 00000000..852365b9 --- /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 00000000..1653204c --- /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 00000000..d240fabc --- /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 00000000..13242641 --- /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 00000000..5dd11b15 --- /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 00000000..802a5de5 --- /dev/null +++ b/src/features/chat/hooks/query/useChatRoomsQuery.ts @@ -0,0 +1,44 @@ +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 + +/** 개인(DIRECT)·전체(GROUP) 방이 한 목록에 섞여 옵니다 — 세그먼트 분기는 화면단에서 합니다 */ +export function useChatRoomsQuery() { + const isLoggedIn = useAuthStore(state => state.isLoggedIn) + const scope = useAuthStore(state => state.scope) + + const query = useInfiniteQuery({ + queryKey: queryKeys.chat.rooms(scope, { 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 00000000..4e8bd6da --- /dev/null +++ b/src/features/chat/hooks/useChatListViewModel.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useChatRoomsQuery } from '@/features/chat/hooks/query/useChatRoomsQuery' +import { + readLastChatSegment, + writeLastChatSegment, +} from '@/features/chat/lib/segmentPreference' +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 roomsQuery = useChatRoomsQuery() + const setUnreadCount = useChatUnreadStore(state => state.setUnreadCount) + + const roomsBySegment = useMemo(() => { + const personal: ChatRoomListItem[] = [] + const group: ChatRoomListItem[] = [] + roomsQuery.rooms.forEach(room => { + if (room.segment === 'group') group.push(room) + else personal.push(room) + }) + return { personal, group } + }, [roomsQuery.rooms]) + + const personalUnread = useMemo( + () => sumUnread(roomsBySegment.personal), + [roomsBySegment.personal] + ) + const groupUnread = useMemo( + () => sumUnread(roomsBySegment.group), + [roomsBySegment.group] + ) + + // Docbar 채팅 뱃지 = 개인 + 전체 합산 + useEffect(() => { + setUnreadCount('personal', personalUnread) + }, [personalUnread, setUnreadCount]) + + useEffect(() => { + setUnreadCount('group', groupUnread) + }, [groupUnread, setUnreadCount]) + + const changeSegment = useCallback((next: ChatSegment) => { + setSegment(next) + setKeyword('') + writeLastChatSegment(next) + }, []) + + const sourceRooms = roomsBySegment[segment] + const { hasNextPage, isFetchingNextPage, fetchNextPage } = roomsQuery + + /** 한 페이지가 전부 반대 세그먼트일 수 있어, 현재 탭이 비었으면 다음 페이지를 더 봅니다 */ + const isAwaitingMorePages = sourceRooms.length === 0 && hasNextPage + + useEffect(() => { + if (!isAwaitingMorePages || isFetchingNextPage) return + void fetchNextPage() + }, [isAwaitingMorePages, isFetchingNextPage, fetchNextPage]) + + const rooms = useMemo( + () => sortRooms(sourceRooms).filter(room => matchesKeyword(room, keyword)), + [sourceRooms, keyword] + ) + + // 자동 추가 로드 중에는 빈 상태 대신 스켈레톤을 유지합니다 + const isLoading = roomsQuery.isLoading || isAwaitingMorePages + const isError = roomsQuery.isError + 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: roomsQuery.refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } +} diff --git a/src/features/chat/hooks/useChatRoomViewModel.ts b/src/features/chat/hooks/useChatRoomViewModel.ts new file mode 100644 index 00000000..9d730f44 --- /dev/null +++ b/src/features/chat/hooks/useChatRoomViewModel.ts @@ -0,0 +1,399 @@ +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, + chatMessageSignature, + mergeChatMessages, + sortMessagesAscending, +} from '@/features/chat/lib/chatTimeline' +import { resolveChatErrorMessage } from '@/features/chat/lib/chatErrorMessage' +import { + CHAT_ATTACHMENT_MAX_COUNT, + type ChatAttachment, + type ChatMessage, + type ChatRoomContext, +} from '@/features/chat/types/chat' +import { uploadAppFile } from '@/shared/api/appFileUpload' +import { queryKeys } from '@/shared/lib/queryKeys' +import useAuthStore from '@/shared/stores/useAuthStore' +import { showToast } from '@/shared/stores/useToastStore' +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() + + /** + * 방 전환 시 초기화해야 하는 화면 상태를 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: !isRoomListLoading && !listRoom, + }) + const room = listRoom ?? detailRoom + const isGroupRoom = room?.segment === 'group' + + /** 전체 채팅방에는 상대방이 없어 발신자 폴백에 쓰면 안 됩니다 */ + const opponentName = isGroupRoom ? undefined : room?.title + + const messagesQuery = useChatMessagesQuery({ + roomId, + myId: user.id, + myScope: scope === 'MANAGER' ? 'MANAGER' : 'USER', + opponentId: room?.opponentId, + opponentScope: room?.opponentScope, + opponentName, + }) + + 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 (!Number.isFinite(roomId)) return + if (lastReadTargetId <= markedReadIdRef.current) return + + markedReadIdRef.current = lastReadTargetId + markReadMutate({ roomId, lastReadMessageId: lastReadTargetId }) + }, [roomId, lastReadTargetId, markReadMutate]) + + 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, + }) + + setRoomState(current => { + if (current.liveMessages.some(existing => existing.id === message.id)) { + return current + } + const echoSignature = chatMessageSignature(message) + return { + ...current, + liveMessages: [...current.liveMessages, message], + // 내가 보낸 메시지의 echo 가 도착하면 낙관적 항목을 제거합니다 + pendingMessages: message.isMine + ? current.pendingMessages.filter( + pending => chatMessageSignature(pending) !== echoSignature + ) + : current.pendingMessages, + } + }) + // 목록의 미리보기·정렬·미읽음을 갱신합니다 + void queryClient.invalidateQueries({ queryKey: queryKeys.chat.roomsAll }) + }, + [ + user.id, + scope, + room?.opponentId, + room?.opponentScope, + opponentName, + queryClient, + ] + ) + + const { connectionState, isConnected, sendMessage } = useChatStomp({ + roomId, + onMessage: handleIncomingMessage, + }) + + const messages = useMemo(() => { + const serverMessages = sortMessagesAscending([ + ...messagesQuery.messages, + ...liveMessages.filter( + live => !messagesQuery.messages.some(loaded => loaded.id === live.id) + ), + ]) + return mergeChatMessages(serverMessages, pendingMessages) + }, [messagesQuery.messages, liveMessages, pendingMessages]) + + const roomContext = useMemo( + () => ({ + id: roomId, + segment: isGroupRoom ? 'group' : 'personal', + title: room?.title ?? (isGroupRoom ? '전체 채팅' : '채팅'), + // 개인 채팅도 서버가 인원수(2)를 주지만 헤더에는 전체 채팅에서만 노출합니다 + memberCount: isGroupRoom ? room?.memberCount : undefined, + }), + [roomId, isGroupRoom, room?.title, room?.memberCount] + ) + + const timeline = useMemo( + () => buildChatTimeline(messages, roomContext.segment), + [messages, roomContext.segment] + ) + + const handleSend = useCallback(() => { + const content = draft.trim() + if (!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, user.id, scope, sendMessage]) + + const [isSendingImages, setSendingImages] = useState(false) + + /** 미리보기용 object URL — 방을 떠날 때 한 번에 해제합니다 */ + const previewUrlsRef = useRef([]) + + useEffect(() => { + const urls = previewUrlsRef.current + return () => { + urls.forEach(url => URL.revokeObjectURL(url)) + previewUrlsRef.current = [] + } + }, [roomId]) + + /** 재시도 때 다시 업로드할 원본 파일 — 방을 떠나면 함께 비웁니다 */ + const pendingFilesRef = useRef(new Map()) + + useEffect(() => { + const files = pendingFilesRef.current + return () => files.clear() + }, [roomId]) + + const setPendingStatus = useCallback( + (clientId: string, status: ChatMessage['status']) => { + setRoomState(current => ({ + ...current, + pendingMessages: current.pendingMessages.map(pending => + pending.clientId === clientId ? { ...pending, status } : pending + ), + })) + }, + [] + ) + + /** 업로드 → STOMP 전송. 최초 전송과 재시도가 같은 경로를 씁니다 */ + const uploadAndPublish = useCallback( + async (clientId: string, files: File[], content: string) => { + setSendingImages(true) + try { + const fileIds = await Promise.all( + files.map(file => + uploadAppFile({ + file, + targetType: 'CHAT_MESSAGE', + // 채팅 이미지는 비공개 버킷 — 조회 시 presigned URL 로 내려옵니다 + bucketType: 'PRIVATE', + scope, + }) + ) + ) + + if (!sendMessage({ content: content || undefined, fileIds })) { + throw new Error('연결이 끊겨 이미지를 보내지 못했습니다.') + } + } catch (error) { + setPendingStatus(clientId, 'failed') + showToast( + resolveChatErrorMessage(error, '이미지를 보내지 못했어요.'), + 'error' + ) + } finally { + setSendingImages(false) + } + }, + [scope, sendMessage, setPendingStatus] + ) + + /** + * 이미지 전송 — 업로드로 fileId 를 받은 뒤 STOMP 로 보냅니다. + * 업로드 대기 동안에는 로컬 미리보기를 pending 으로 띄웁니다. + */ + const sendImages = useCallback( + async (files: File[]) => { + if (files.length === 0) return + + if (files.length > CHAT_ATTACHMENT_MAX_COUNT) { + showToast( + `이미지는 한 번에 ${CHAT_ATTACHMENT_MAX_COUNT}장까지 보낼 수 있어요.`, + 'error' + ) + return + } + + const clientId = createClientId() + const previews: ChatAttachment[] = files.map((file, index) => { + const url = URL.createObjectURL(file) + previewUrlsRef.current.push(url) + return { fileId: `${clientId}-${index}`, url } + }) + + // 입력창에 쓰던 글이 있으면 이미지와 한 메시지로 함께 보냅니다 + const content = draft.trim() + + setRoomState(current => ({ + ...current, + draft: '', + isAttachmentOpen: false, + pendingMessages: [ + ...current.pendingMessages, + { + id: -Date.now(), + clientId, + senderId: user.id ?? -1, + senderScope: scope === 'MANAGER' ? 'MANAGER' : 'USER', + senderName: '', + senderProfileImageUrl: null, + content, + createdAt: new Date().toISOString(), + isMine: true, + status: 'pending', + messageType: 'NORMAL', + attachments: previews, + }, + ], + })) + + pendingFilesRef.current.set(clientId, files) + await uploadAndPublish(clientId, files, content) + }, + [draft, user.id, scope, uploadAndPublish] + ) + + /** + * 실패한 메시지 재전송. + * 이미지 메시지는 업로드부터 다시 합니다 — 앞선 시도에서 fileId 를 받았는지 알 수 없어서 + * 그대로 다시 올리는 편이 안전합니다. + */ + const retryFailedMessage = useCallback( + (clientId: string) => { + const target = pendingMessages.find( + pending => pending.clientId === clientId + ) + if (!target || target.status !== 'failed') return + + const files = pendingFilesRef.current.get(clientId) + + if (files?.length) { + setPendingStatus(clientId, 'pending') + void uploadAndPublish(clientId, files, target.content) + return + } + + setPendingStatus( + clientId, + sendMessage({ content: target.content }) ? 'pending' : 'failed' + ) + }, + [pendingMessages, sendMessage, setPendingStatus, uploadAndPublish] + ) + + return { + room: roomContext, + timeline, + messages, + isLoading: messagesQuery.isLoading, + isError: messagesQuery.isError, + isEmpty: messages.length === 0, + refetch: messagesQuery.refetch, + hasOlderMessages: messagesQuery.hasOlderMessages, + isFetchingOlderMessages: messagesQuery.isFetchingOlderMessages, + fetchOlderMessages: messagesQuery.fetchOlderMessages, + draft, + setDraft, + handleSend, + sendImages, + isSendingImages, + retryFailedMessage, + connectionState, + isConnected, + isAttachmentOpen, + toggleAttachment, + } +} diff --git a/src/features/chat/hooks/useChatStomp.ts b/src/features/chat/hooks/useChatStomp.ts new file mode 100644 index 00000000..4c0a77d4 --- /dev/null +++ b/src/features/chat/hooks/useChatStomp.ts @@ -0,0 +1,96 @@ +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]) + + /** `content` 와 `fileIds` 가 둘 다 비면 서버가 거부하므로 호출부에서 걸러 보냅니다 */ + const sendMessage = useCallback( + (message: { content?: string; fileIds?: string[] }) => { + const payload: SendChatMessagePayload = { + type: 'NORMAL', + ...(message.content && { content: message.content }), + ...(message.fileIds?.length && { fileIds: message.fileIds }), + } + 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 00000000..2e99601a --- /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/index.ts b/src/features/chat/index.ts new file mode 100644 index 00000000..e8b18a9a --- /dev/null +++ b/src/features/chat/index.ts @@ -0,0 +1,53 @@ +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_ATTACHMENT_MAX_COUNT, + CHAT_MESSAGE_MAX_LENGTH, + CHAT_SEGMENTS, + CHAT_SEGMENT_LABEL, +} from './types/chat' +export type { + ChatConnectionState, + ChatContact, + ChatMessage, + ChatMessageStatus, + ChatRoomContext, + ChatRoomDetail, + ChatRoomListItem, + ChatRoomSummary, + ChatSegment, + ChatTimelineEntry, +} from './types/chat' +export type { + ChatMessageDto, + ChatParticipantScope, + ChatRoomDetailDto, + ChatRoomListItemDto, + ChatRoomTypeValue, + CreateChatRoomRequest, +} from './types/dto' diff --git a/src/features/chat/lib/adaptChat.ts b/src/features/chat/lib/adaptChat.ts new file mode 100644 index 00000000..b61f1e22 --- /dev/null +++ b/src/features/chat/lib/adaptChat.ts @@ -0,0 +1,155 @@ +import type { + ChatMessage, + ChatRoomDetail, + ChatRoomListItem, + ChatRoomSummary, + ChatSegment, +} from '@/features/chat/types/chat' +import type { + ChatMessageDto, + ChatParticipantScope, + ChatRoomDetailDto, + ChatRoomListItemDto, + ChatRoomTypeDto, + ChatRoomTypeValue, + ChatScopeDto, + ChatServerScope, + DescribedEnumDto, +} from '@/features/chat/types/dto' + +/** `"APP"` 과 `{ value: "APP" }` 두 직렬화를 모두 값으로 풉니다 */ +function unwrapEnum( + value: DescribedEnumDto | null | undefined +): T | undefined { + if (value === null || value === undefined) return undefined + return typeof value === 'string' ? value : value.value +} + +/** + * scope 를 도메인 표현으로 좁힙니다. + * 서버는 평문 `"APP"` 과 `{ value: "APP" }` 두 형태를 섞어 쓰고, 알바생은 APP 으로 옵니다. + */ +export function toParticipantScope( + value: ChatScopeDto | null | undefined +): ChatParticipantScope { + return unwrapEnum(value) === 'MANAGER' ? 'MANAGER' : 'USER' +} + +/** 상대방이 없는 전체 채팅방과 구분해야 해서 값이 없으면 undefined 로 둡니다 */ +function toOptionalParticipantScope( + value: ChatScopeDto | null | undefined +): ChatParticipantScope | undefined { + return unwrapEnum(value) === undefined ? undefined : toParticipantScope(value) +} + +/** 요청 바디용 — 서버 enum 은 USER 대신 APP */ +export function toServerScope(scope: ChatParticipantScope): ChatServerScope { + return scope === 'MANAGER' ? 'MANAGER' : 'APP' +} + +/** + * 방 타입 → 세그먼트. + * `type` 이 없는 구 배포본에서는 상대방 유무로 추론합니다(GROUP 은 opponent 가 전부 null). + */ +export function toChatSegment( + type: ChatRoomTypeDto | null | undefined, + opponentId: number | null | undefined +): ChatSegment { + const value: ChatRoomTypeValue | undefined = unwrapEnum(type) + if (value === 'GROUP') return 'group' + if (value === 'DIRECT') return 'personal' + return opponentId === null || opponentId === undefined ? 'group' : 'personal' +} + +/** 목록·상세가 공유하는 필드셋 변환 */ +function adaptChatRoomSummary( + dto: ChatRoomListItemDto | ChatRoomDetailDto +): ChatRoomSummary { + return { + id: dto.id, + segment: toChatSegment(dto.type, dto.opponentId), + // 서버가 roomName 을 주지 않는 구 배포본에서는 상대 이름으로 폴백합니다 + title: dto.roomName ?? dto.opponentName ?? '알 수 없음', + profileImageUrl: dto.opponentProfileImageUrl ?? null, + memberCount: dto.memberCount, + opponentId: dto.opponentId ?? undefined, + opponentScope: toOptionalParticipantScope(dto.opponentScope), + } +} + +export function adaptChatRoomListItem( + dto: ChatRoomListItemDto +): ChatRoomListItem { + return { + ...adaptChatRoomSummary(dto), + latestMessage: dto.latestMessageContent ?? '', + updatedAt: dto.updatedAt, + unreadCount: dto.unreadCount ?? 0, + } +} + +export function adaptChatRoomDetail(dto: ChatRoomDetailDto): ChatRoomDetail { + return adaptChatRoomSummary(dto) +} + +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 00000000..2fc5cf96 --- /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 00000000..55d03e14 --- /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 00000000..71559e33 --- /dev/null +++ b/src/features/chat/lib/chatTimeline.ts @@ -0,0 +1,86 @@ +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 를 짝지을 키. + * 본문만 쓰면 이미지 전용 메시지(본문 빈 문자열)끼리 서로 지워버려 첨부 수까지 함께 봅니다. + */ +export function chatMessageSignature(message: ChatMessage): string { + return `${message.content}|${message.attachments.length}` +} + +/** + * 낙관적 메시지와 서버 메시지를 합칩니다. + * 서버 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(chatMessageSignature) + ) + + const remainingPending = pendingMessages.filter( + pending => + pending.status === 'failed' || + !serverSignatures.has(chatMessageSignature(pending)) + ) + + 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 00000000..6bd1022f --- /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 00000000..21ca1e6f --- /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 00000000..8a07ec98 --- /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/test/api/chatRoom.test.ts b/src/features/chat/test/api/chatRoom.test.ts new file mode 100644 index 00000000..414085f1 --- /dev/null +++ b/src/features/chat/test/api/chatRoom.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import axiosInstance from '@/shared/lib/axiosInstance' +import { queryKeys } from '@/shared/lib/queryKeys' + +import { + createChatRoom, + fetchChatMessages, + fetchChatRoomDetail, + fetchChatRooms, + markChatRoomRead, +} from '../../api/chatRoom' +import { toServerScope } from '../../lib/adaptChat' +import { + chatPublishDestination, + chatSubscribeDestination, +} from '../../lib/stompDestinations' + +vi.mock('@/shared/lib/axiosInstance', () => ({ + default: { get: vi.fn(), post: vi.fn() }, +})) + +const get = vi.mocked(axiosInstance.get) +const post = vi.mocked(axiosInstance.post) + +const emptyPage = { + page: { cursor: null, pageSize: 20, totalCount: 0 }, + data: [], +} + +describe('채팅 API 스코프 분기', () => { + beforeEach(() => { + get.mockReset() + post.mockReset() + get.mockResolvedValue({ data: emptyPage }) + post.mockResolvedValue({ data: { chatRoomId: 1 } }) + }) + + it('알바생(USER)은 /app 경로로 방 목록을 조회한다', async () => { + await fetchChatRooms('USER', { pageSize: 20 }) + + expect(get).toHaveBeenCalledWith('/app/chat/rooms', { + params: { pageSize: 20 }, + }) + }) + + it('사장님(MANAGER)은 /manager 경로로 방 목록을 조회한다', async () => { + await fetchChatRooms('MANAGER', { pageSize: 20 }) + + expect(get).toHaveBeenCalledWith('/manager/chat/rooms', { + params: { pageSize: 20 }, + }) + }) + + it('스코프가 없으면 /app 으로 폴백한다', async () => { + await fetchChatRooms(null, { pageSize: 20 }) + + expect(get).toHaveBeenCalledWith('/app/chat/rooms', { + params: { pageSize: 20 }, + }) + }) + + it('커서가 있으면 파라미터에 포함한다', async () => { + await fetchChatRooms('USER', { pageSize: 20, cursor: 'c1' }) + + expect(get).toHaveBeenCalledWith('/app/chat/rooms', { + params: { pageSize: 20, cursor: 'c1' }, + }) + }) + + it('메시지 목록은 방 하위 경로로 조회한다', async () => { + await fetchChatMessages('MANAGER', 42, { pageSize: 30 }) + + expect(get).toHaveBeenCalledWith('/manager/chat/rooms/42/messages', { + params: { pageSize: 30 }, + }) + }) + + it('방 상세는 방 경로로 조회한다', async () => { + get.mockResolvedValue({ data: { data: { id: 42 } } }) + await fetchChatRoomDetail('USER', 42) + + expect(get).toHaveBeenCalledWith('/app/chat/rooms/42') + }) + + it('읽음 처리는 어디까지 읽었는지 바디로 보낸다', async () => { + await markChatRoomRead('USER', 42, 1024) + + expect(post).toHaveBeenCalledWith('/app/chat/rooms/42/read', { + lastReadMessageId: 1024, + }) + }) +}) + +describe('채팅방 생성 응답 언랩', () => { + beforeEach(() => { + post.mockReset() + }) + + it('CommonApiResponse 로 감싸진 응답에서 chatRoomId 를 꺼낸다', async () => { + post.mockResolvedValue({ + data: { timestamp: '2026-08-19T00:00:00Z', data: { chatRoomId: 7 } }, + }) + + await expect( + createChatRoom('USER', { opponentUserId: 3, opponentScope: 'MANAGER' }) + ).resolves.toEqual({ chatRoomId: 7 }) + }) + + it('감싸지 않은 응답도 그대로 사용한다', async () => { + post.mockResolvedValue({ data: { chatRoomId: 9 } }) + + await expect( + createChatRoom('USER', { opponentUserId: 3, opponentScope: 'MANAGER' }) + ).resolves.toEqual({ chatRoomId: 9 }) + }) + + it('알바생 상대는 서버 enum 인 APP 으로 보낸다', async () => { + post.mockResolvedValue({ data: { chatRoomId: 1 } }) + + await createChatRoom('MANAGER', { + opponentUserId: 3, + opponentScope: toServerScope('USER'), + }) + + expect(post).toHaveBeenCalledWith('/manager/chat/rooms', { + opponentUserId: 3, + opponentScope: 'APP', + }) + }) +}) + +describe('STOMP 목적지', () => { + it('구독은 스코프와 무관하게 방 단위다', () => { + expect(chatSubscribeDestination(42)).toBe('/sub/chat.42') + }) + + it('발행은 스코프별 경로를 쓴다', () => { + expect(chatPublishDestination('USER', 42)).toBe('/pub/app/send.42') + expect(chatPublishDestination('MANAGER', 42)).toBe('/pub/manager/send.42') + }) +}) + +describe('채팅 쿼리 키', () => { + it('스코프별로 목록 캐시가 분리된다', () => { + expect(queryKeys.chat.rooms('USER', { pageSize: 20 })).not.toEqual( + queryKeys.chat.rooms('MANAGER', { pageSize: 20 }) + ) + }) + + it('목록 무효화 prefix 는 개별 키의 앞부분과 일치한다', () => { + const key = queryKeys.chat.rooms('USER', { pageSize: 20 }) + expect(key.slice(0, 2)).toEqual([...queryKeys.chat.roomsAll]) + }) +}) diff --git a/src/features/chat/test/lib/adaptChat.test.ts b/src/features/chat/test/lib/adaptChat.test.ts new file mode 100644 index 00000000..f343124f --- /dev/null +++ b/src/features/chat/test/lib/adaptChat.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from 'vitest' + +import type { + ChatMessageDto, + ChatRoomListItemDto, +} from '@/features/chat/types/dto' + +import { + adaptChatMessage, + adaptChatRoomDetail, + adaptChatRoomListItem, + toParticipantScope, + toServerScope, +} from '../../lib/adaptChat' +import { clampMessageDraft, canSendMessage } from '../../lib/messageDraft' +import { CHAT_MESSAGE_MAX_LENGTH } from '@/features/chat/types/chat' + +function roomDto( + overrides: Partial = {} +): ChatRoomListItemDto { + return { + id: 1, + type: { value: 'DIRECT', description: '개인 채팅' }, + roomName: '최민석 점주님', + memberCount: 2, + opponentId: 200, + opponentScope: 'MANAGER', + opponentName: '최민석 점주님', + latestMessageContent: '근무표 확정해서 공유드려요', + createdAt: '2026-08-19T09:00:00', + updatedAt: '2026-08-19T09:10:00', + ...overrides, + } +} + +/** 전체 채팅방 — 상대방 필드가 전부 null 로 내려옵니다 */ +function groupRoomDto( + overrides: Partial = {} +): ChatRoomListItemDto { + return roomDto({ + id: 34, + type: { value: 'GROUP', description: '그룹 채팅' }, + roomName: '알터 카페 강남점', + memberCount: 8, + opponentId: null, + opponentScope: null, + opponentName: null, + opponentProfileImageUrl: null, + latestMessageContent: '이번 주 스케줄 공유합니다', + ...overrides, + }) +} + +function messageDto(overrides: Partial = {}): ChatMessageDto { + return { + id: 10, + senderId: 200, + senderScope: 'MANAGER', + content: '확인 부탁드려요', + createdAt: '2026-08-19T09:10:00', + ...overrides, + } +} + +describe('scope 정규화', () => { + it('서버의 APP 은 도메인 USER 로 좁힌다', () => { + expect(toParticipantScope('APP')).toBe('USER') + }) + + it('객체로 감싸 내려오는 scope 도 언랩한다', () => { + expect(toParticipantScope({ value: 'MANAGER', description: '점주' })).toBe( + 'MANAGER' + ) + expect(toParticipantScope({ value: 'APP' })).toBe('USER') + }) + + it('알 수 없는 값·누락은 USER 로 폴백한다', () => { + expect(toParticipantScope('ROBOT')).toBe('USER') + expect(toParticipantScope(undefined)).toBe('USER') + }) + + it('요청 바디로 나갈 때는 USER 를 APP 으로 되돌린다', () => { + expect(toServerScope('USER')).toBe('APP') + expect(toServerScope('MANAGER')).toBe('MANAGER') + }) +}) + +describe('채팅방 목록 DTO 변환', () => { + it('스펙에 없는 미읽음·프로필은 폴백한다', () => { + const item = adaptChatRoomListItem(roomDto()) + + expect(item.unreadCount).toBe(0) + expect(item.profileImageUrl).toBeNull() + expect(item.segment).toBe('personal') + }) + + it('DIRECT 방은 roomName 을 제목으로 쓰고 상대 정보를 채운다', () => { + const item = adaptChatRoomListItem( + roomDto({ opponentProfileImageUrl: 'https://cdn/p.png' }) + ) + + expect(item.title).toBe('최민석 점주님') + expect(item.profileImageUrl).toBe('https://cdn/p.png') + expect(item.opponentId).toBe(200) + expect(item.memberCount).toBe(2) + }) + + it('GROUP 방은 전체 세그먼트로 넘기고 업장명·인원수를 쓴다', () => { + const item = adaptChatRoomListItem(groupRoomDto()) + + expect(item.segment).toBe('group') + expect(item.title).toBe('알터 카페 강남점') + expect(item.memberCount).toBe(8) + }) + + it('GROUP 방의 상대 정보는 null 대신 undefined 로 둔다', () => { + const item = adaptChatRoomListItem(groupRoomDto()) + + // USER 로 폴백하면 상대 없는 방을 1:1 처럼 다루게 됩니다 + expect(item.opponentId).toBeUndefined() + expect(item.opponentScope).toBeUndefined() + }) + + it('type 이 없는 구 배포본은 상대방 유무로 세그먼트를 추론한다', () => { + expect(adaptChatRoomListItem(roomDto({ type: undefined })).segment).toBe( + 'personal' + ) + expect( + adaptChatRoomListItem(groupRoomDto({ type: undefined })).segment + ).toBe('group') + }) + + it('roomName 이 없으면 상대 이름으로, 그마저 없으면 안내 문구로 폴백한다', () => { + expect(adaptChatRoomListItem(roomDto({ roomName: null })).title).toBe( + '최민석 점주님' + ) + expect( + adaptChatRoomListItem(roomDto({ roomName: null, opponentName: null })) + .title + ).toBe('알 수 없음') + }) + + it('미읽음이 오면 그대로 사용한다', () => { + expect( + adaptChatRoomListItem(roomDto({ unreadCount: 12 })).unreadCount + ).toBe(12) + }) + + it('최근 메시지가 없는 방은 빈 문자열로 둔다', () => { + expect( + adaptChatRoomListItem(roomDto({ latestMessageContent: null })) + .latestMessage + ).toBe('') + }) + + it('목록의 평문 APP scope 를 USER 로 좁힌다', () => { + expect( + adaptChatRoomListItem(roomDto({ opponentScope: 'APP' })).opponentScope + ).toBe('USER') + }) +}) + +describe('방 상세 DTO 변환', () => { + it('상대 이름을 헤더 제목으로 쓰고 객체형 scope 를 언랩한다', () => { + const detail = adaptChatRoomDetail({ + id: 5, + type: { value: 'DIRECT', description: '개인 채팅' }, + roomName: '최민석 점주님', + memberCount: 2, + opponentId: 200, + opponentScope: { value: 'MANAGER', description: '점주' }, + opponentName: '최민석 점주님', + createdAt: '2026-08-19T09:00:00', + updatedAt: '2026-08-19T09:10:00', + }) + + expect(detail.segment).toBe('personal') + expect(detail.title).toBe('최민석 점주님') + expect(detail.opponentScope).toBe('MANAGER') + expect(detail.profileImageUrl).toBeNull() + }) + + it('GROUP 방 상세는 업장명·인원수만 채우고 상대는 비운다', () => { + const detail = adaptChatRoomDetail({ + id: 34, + type: { value: 'GROUP', description: '그룹 채팅' }, + roomName: '알터 카페 강남점', + memberCount: 8, + opponentId: null, + opponentScope: null, + opponentName: null, + createdAt: '2026-08-01T09:00:00', + updatedAt: '2026-08-20T08:40:00', + }) + + expect(detail.segment).toBe('group') + expect(detail.title).toBe('알터 카페 강남점') + expect(detail.memberCount).toBe(8) + expect(detail.opponentId).toBeUndefined() + }) +}) + +describe('메시지 DTO 변환 — 내 메시지 판별', () => { + it('서버의 isMine 을 최우선으로 쓴다', () => { + expect( + adaptChatMessage(messageDto({ isMine: true }), { myId: 999 }).isMine + ).toBe(true) + }) + + it('객체형 senderScope 를 언랩해 내 메시지를 판별한다', () => { + expect( + adaptChatMessage( + messageDto({ senderId: 5, senderScope: { value: 'APP' } }), + { myId: 5, myScope: 'USER' } + ).isMine + ).toBe(true) + }) + + it('id 가 같아도 scope 가 다르면 내 메시지가 아니다', () => { + expect( + adaptChatMessage( + messageDto({ senderId: 5, senderScope: { value: 'MANAGER' } }), + { myId: 5, myScope: 'USER' } + ).isMine + ).toBe(false) + }) + + it('내 id 를 모르면 1:1 방의 상대 여부로 판별한다', () => { + const fromOpponent = adaptChatMessage(messageDto(), { + opponentId: 200, + opponentScope: 'MANAGER', + }) + const fromMe = adaptChatMessage( + messageDto({ senderId: 7, senderScope: { value: 'APP' } }), + { opponentId: 200, opponentScope: 'MANAGER' } + ) + + expect(fromOpponent.isMine).toBe(false) + expect(fromMe.isMine).toBe(true) + }) + + it('서버가 준 발신자 이름·프로필을 그대로 쓴다', () => { + // 단체방 발신자 표기는 이 필드에만 의존합니다(상대 개념이 없어 폴백이 불가) + const adapted = adaptChatMessage( + messageDto({ + senderName: '이서준', + senderProfileImageUrl: 'https://cdn/sender.png', + }) + ) + + expect(adapted.senderName).toBe('이서준') + expect(adapted.senderProfileImageUrl).toBe('https://cdn/sender.png') + }) + + it('발신자 이름이 없으면 받은 메시지에 상대 이름을 채운다', () => { + const adapted = adaptChatMessage(messageDto(), { + opponentId: 200, + opponentScope: 'MANAGER', + opponentName: '최민석 점주님', + }) + + expect(adapted.senderName).toBe('최민석 점주님') + }) +}) + +describe('메시지 DTO 변환 — 타입·첨부', () => { + it('타입이 없으면 일반 메시지로 본다', () => { + const adapted = adaptChatMessage(messageDto()) + + expect(adapted.messageType).toBe('NORMAL') + expect(adapted.attachments).toEqual([]) + }) + + it('공지 메시지를 구분해 표시할 수 있다', () => { + expect(adaptChatMessage(messageDto({ type: 'NOTICE' })).messageType).toBe( + 'NOTICE' + ) + }) + + it('이미지 전용 메시지는 본문을 빈 문자열로 두고 첨부만 남긴다', () => { + const adapted = adaptChatMessage( + messageDto({ + content: null, + attachments: [{ fileId: 'f1', url: 'https://cdn/f1.png' }], + }) + ) + + expect(adapted.content).toBe('') + expect(adapted.attachments).toHaveLength(1) + }) + + it('메시지별 안 읽은 사람 수를 그대로 보존한다', () => { + expect(adaptChatMessage(messageDto({ unreadCount: 3 })).unreadCount).toBe(3) + }) +}) + +describe('메시지 입력 제한', () => { + it('1000자를 넘는 입력은 잘라낸다', () => { + const long = 'ㄱ'.repeat(CHAT_MESSAGE_MAX_LENGTH + 50) + + expect(clampMessageDraft(long)).toHaveLength(CHAT_MESSAGE_MAX_LENGTH) + }) + + it('공백만 있으면 전송할 수 없다', () => { + expect(canSendMessage(' \n ')).toBe(false) + expect(canSendMessage('')).toBe(false) + }) + + it('내용이 있으면 전송할 수 있다', () => { + expect(canSendMessage(' 안녕하세요 ')).toBe(true) + }) +}) diff --git a/src/features/chat/test/lib/chatTime.test.ts b/src/features/chat/test/lib/chatTime.test.ts new file mode 100644 index 00000000..8351978c --- /dev/null +++ b/src/features/chat/test/lib/chatTime.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { + formatChatListTime, + formatDateDivider, + formatMessageTime, + isSameDay, +} from '../../lib/chatTime' + +describe('채팅 시각 표기', () => { + it('말풍선 옆 시각은 오전·오후 12시간제로 표기한다', () => { + expect(formatMessageTime('2026-08-19T11:02:00')).toBe('오전 11:02') + expect(formatMessageTime('2026-08-19T13:05:00')).toBe('오후 1:05') + }) + + it('자정과 정오는 12로 표기한다', () => { + expect(formatMessageTime('2026-08-19T00:07:00')).toBe('오전 12:07') + expect(formatMessageTime('2026-08-19T12:00:00')).toBe('오후 12:00') + }) + + it('잘못된 값은 빈 문자열을 반환한다', () => { + expect(formatMessageTime('not-a-date')).toBe('') + expect(formatDateDivider('not-a-date')).toBe('') + expect(formatChatListTime('not-a-date')).toBe('') + }) +}) + +describe('날짜 구분선', () => { + const now = new Date('2026-08-19T09:00:00') + + it('올해 날짜는 연도를 생략한다', () => { + expect(formatDateDivider('2026-08-19T09:00:00', now)).toBe('8월 19일 (수)') + }) + + it('다른 해 날짜는 연도를 붙인다', () => { + expect(formatDateDivider('2025-12-25T09:00:00', now)).toBe( + '2025년 12월 25일 (목)' + ) + }) +}) + +describe('같은 날 판별', () => { + it('시각이 달라도 같은 날이면 true', () => { + expect(isSameDay('2026-08-19T00:01:00', '2026-08-19T23:59:00')).toBe(true) + }) + + it('하루라도 다르면 false', () => { + expect(isSameDay('2026-08-19T23:59:00', '2026-08-20T00:01:00')).toBe(false) + }) + + it('잘못된 값이 섞이면 false', () => { + expect(isSameDay('nope', '2026-08-19T00:01:00')).toBe(false) + }) +}) + +describe('채팅 목록 상대 시각', () => { + const now = new Date('2026-08-19T15:00:00') + + it('오늘은 시각으로 표기한다', () => { + expect(formatChatListTime('2026-08-19T09:10:00', now)).toBe('오전 9:10') + }) + + it('어제는 어제로 표기한다', () => { + expect(formatChatListTime('2026-08-18T22:00:00', now)).toBe('어제') + }) + + it('올해의 지난 날짜는 월·일로 표기한다', () => { + expect(formatChatListTime('2026-07-02T22:00:00', now)).toBe('7월 2일') + }) + + it('작년 날짜는 연도를 포함한다', () => { + expect(formatChatListTime('2025-11-03T22:00:00', now)).toBe('2025. 11. 3.') + }) +}) diff --git a/src/features/chat/test/lib/chatTimeline.test.ts b/src/features/chat/test/lib/chatTimeline.test.ts new file mode 100644 index 00000000..5f2efb1b --- /dev/null +++ b/src/features/chat/test/lib/chatTimeline.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' + +import type { ChatMessage } from '@/features/chat/types/chat' + +import { + buildChatTimeline, + mergeChatMessages, + sortMessagesAscending, +} from '../../lib/chatTimeline' + +function message( + overrides: Partial & { id: number } +): ChatMessage { + return { + senderId: 100, + senderScope: 'USER', + senderName: '이서준', + senderProfileImageUrl: null, + content: '안녕하세요', + createdAt: '2026-08-19T09:00:00', + isMine: false, + status: 'sent', + messageType: 'NORMAL', + attachments: [], + ...overrides, + } +} + +describe('채팅 타임라인 구성', () => { + it('첫 메시지 앞에 날짜 구분선을 넣는다', () => { + const timeline = buildChatTimeline([message({ id: 1 })], 'personal') + + expect(timeline[0].kind).toBe('date') + expect(timeline[1].kind).toBe('message') + }) + + it('날짜가 바뀔 때만 구분선을 추가한다', () => { + const timeline = buildChatTimeline( + [ + message({ id: 1, createdAt: '2026-08-19T09:00:00' }), + message({ id: 2, createdAt: '2026-08-19T18:00:00' }), + message({ id: 3, createdAt: '2026-08-20T09:00:00' }), + ], + 'personal' + ) + + expect(timeline.filter(entry => entry.kind === 'date')).toHaveLength(2) + }) + + it('개인 채팅에서는 발신자 메타를 노출하지 않는다', () => { + const timeline = buildChatTimeline([message({ id: 1 })], 'personal') + const entry = timeline.find(item => item.kind === 'message') + + expect(entry?.kind === 'message' && entry.showSenderMeta).toBe(false) + }) + + it('전체 채팅에서는 발신자가 바뀌는 첫 메시지에만 메타를 노출한다', () => { + const timeline = buildChatTimeline( + [ + message({ id: 1, senderId: 100, createdAt: '2026-08-19T09:00:00' }), + message({ id: 2, senderId: 100, createdAt: '2026-08-19T09:01:00' }), + message({ id: 3, senderId: 200, createdAt: '2026-08-19T09:02:00' }), + ], + 'group' + ) + + const metaFlags = timeline + .filter(entry => entry.kind === 'message') + .map(entry => entry.kind === 'message' && entry.showSenderMeta) + + expect(metaFlags).toEqual([true, false, true]) + }) + + it('전체 채팅에서 내 메시지에는 발신자 메타를 붙이지 않는다', () => { + const timeline = buildChatTimeline( + [message({ id: 1, isMine: true, senderId: -1 })], + 'group' + ) + const entry = timeline.find(item => item.kind === 'message') + + expect(entry?.kind === 'message' && entry.showSenderMeta).toBe(false) + }) +}) + +describe('낙관적 메시지 병합', () => { + it('서버 echo 가 도착한 pending 은 제거한다', () => { + const server = [message({ id: 10, isMine: true, content: '보냈어요' })] + const pending = [ + message({ + id: -1, + clientId: 'pending-1', + isMine: true, + content: '보냈어요', + status: 'pending', + }), + ] + + expect(mergeChatMessages(server, pending)).toHaveLength(1) + }) + + it('아직 echo 가 없는 pending 은 유지한다', () => { + const pending = [ + message({ + id: -1, + clientId: 'pending-1', + isMine: true, + content: '아직 안 옴', + status: 'pending', + }), + ] + + expect(mergeChatMessages([], pending)).toHaveLength(1) + }) + + it('전송 실패 메시지는 같은 내용이 서버에 있어도 남긴다', () => { + const server = [message({ id: 10, isMine: true, content: '중복' })] + const pending = [ + message({ + id: -1, + clientId: 'pending-1', + isMine: true, + content: '중복', + status: 'failed', + }), + ] + + expect(mergeChatMessages(server, pending)).toHaveLength(2) + }) + + it('이미지 전용 메시지끼리 본문이 비었다고 서로 지우지 않는다', () => { + // 첨부 1장짜리 echo 가 도착해도 아직 안 올라간 2장짜리 pending 은 남아야 한다 + const server = [ + message({ + id: 10, + isMine: true, + content: '', + attachments: [{ fileId: 'server-1', url: 'https://cdn/1.png' }], + }), + ] + const pending = [ + message({ + id: -1, + clientId: 'pending-1', + isMine: true, + content: '', + attachments: [ + { fileId: 'local-0', url: 'blob:0' }, + { fileId: 'local-1', url: 'blob:1' }, + ], + }), + ] + + expect(mergeChatMessages(server, pending)).toHaveLength(2) + }) + + it('첨부 수까지 같은 이미지 메시지는 echo 로 보고 제거한다', () => { + const attachments = [{ fileId: 'x', url: 'https://cdn/x.png' }] + const server = [message({ id: 10, isMine: true, content: '', attachments })] + const pending = [ + message({ + id: -1, + clientId: 'pending-1', + isMine: true, + content: '', + attachments: [{ fileId: 'local-0', url: 'blob:0' }], + }), + ] + + expect(mergeChatMessages(server, pending)).toHaveLength(1) + }) +}) + +describe('메시지 정렬', () => { + it('오래된 순으로 정렬하고 동일 시각은 id 순으로 둔다', () => { + const sorted = sortMessagesAscending([ + message({ id: 3, createdAt: '2026-08-19T09:02:00' }), + message({ id: 2, createdAt: '2026-08-19T09:00:00' }), + message({ id: 1, createdAt: '2026-08-19T09:00:00' }), + ]) + + expect(sorted.map(item => item.id)).toEqual([1, 2, 3]) + }) +}) diff --git a/src/features/chat/test/lib/unreadAndBroker.test.ts b/src/features/chat/test/lib/unreadAndBroker.test.ts new file mode 100644 index 00000000..0fb347cb --- /dev/null +++ b/src/features/chat/test/lib/unreadAndBroker.test.ts @@ -0,0 +1,138 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' + +import { formatUnreadCount } from '@/shared/lib/unreadCount' +import { resolveBrokerUrl } from '@/shared/lib/stompConnection' +import { + selectTotalChatUnread, + useChatUnreadStore, +} from '@/shared/stores/useChatUnreadStore' + +import { + readLastChatSegment, + writeLastChatSegment, +} from '../../lib/segmentPreference' + +/** unit 프로젝트는 node 환경이라 브라우저 전역을 최소한으로 흉내 냅니다 */ +function createLocalStorageStub(options: { throwOnRead?: boolean } = {}) { + const store = new Map() + return { + getItem: (key: string) => { + if (options.throwOnRead) throw new Error('blocked') + return store.get(key) ?? null + }, + setItem: (key: string, value: string) => { + store.set(key, value) + }, + clear: () => store.clear(), + } +} + +function setWindowStub(overrides: Record = {}) { + Object.defineProperty(globalThis, 'window', { + value: { + location: { protocol: 'http:', host: 'localhost:5173' }, + localStorage: createLocalStorageStub(), + ...overrides, + }, + configurable: true, + writable: true, + }) +} + +describe('미읽음 뱃지 표기', () => { + it('99 이하는 숫자를 그대로 쓴다', () => { + expect(formatUnreadCount(1)).toBe('1') + expect(formatUnreadCount(99)).toBe('99') + }) + + it('99를 넘으면 99+ 로 축약한다', () => { + expect(formatUnreadCount(100)).toBe('99+') + }) +}) + +describe('Docbar 채팅 뱃지 합계', () => { + afterEach(() => { + useChatUnreadStore.getState().reset() + }) + + it('개인과 전체 미읽음을 합산한다', () => { + useChatUnreadStore.getState().setUnreadCount('personal', 3) + useChatUnreadStore.getState().setUnreadCount('group', 1) + + expect(selectTotalChatUnread(useChatUnreadStore.getState())).toBe(4) + }) + + it('음수는 0으로 보정한다', () => { + useChatUnreadStore.getState().setUnreadCount('personal', -5) + + expect(selectTotalChatUnread(useChatUnreadStore.getState())).toBe(0) + }) +}) + +describe('STOMP 브로커 주소 해석', () => { + beforeAll(() => setWindowStub()) + afterAll(() => { + Reflect.deleteProperty(globalThis, 'window') + }) + + it('ws·wss 절대 주소는 그대로 쓴다', () => { + expect(resolveBrokerUrl('wss://api.example.com/ws-connect')).toBe( + 'wss://api.example.com/ws-connect' + ) + }) + + it('상대 경로는 현재 오리진 기준으로 만든다', () => { + expect(resolveBrokerUrl('/api/ws-connect')).toBe( + 'ws://localhost:5173/api/ws-connect' + ) + }) + + it('앞 슬래시가 없어도 경로로 해석한다', () => { + expect(resolveBrokerUrl('api/ws-connect')).toBe( + 'ws://localhost:5173/api/ws-connect' + ) + }) + + it('https 오리진에서는 wss 로 붙는다', () => { + setWindowStub({ + location: { protocol: 'https:', host: 'alter-app.com' }, + }) + + expect(resolveBrokerUrl('/api/ws-connect')).toBe( + 'wss://alter-app.com/api/ws-connect' + ) + }) +}) + +describe('마지막 세그먼트 기억', () => { + afterAll(() => { + Reflect.deleteProperty(globalThis, 'window') + }) + + it('브라우저 환경이 아니면 개인 채팅으로 폴백한다', () => { + Reflect.deleteProperty(globalThis, 'window') + + expect(readLastChatSegment()).toBe('personal') + }) + + it('저장된 값이 없으면 개인 채팅이 기본이다', () => { + setWindowStub() + + expect(readLastChatSegment()).toBe('personal') + }) + + it('전체 채팅을 고르면 다음 진입 시 유지된다', () => { + setWindowStub() + writeLastChatSegment('group') + + expect(readLastChatSegment()).toBe('group') + }) + + it('스토리지 읽기가 막혀도 기본값으로 동작한다', () => { + setWindowStub({ + localStorage: createLocalStorageStub({ throwOnRead: true }), + }) + + expect(readLastChatSegment()).toBe('personal') + }) +}) diff --git a/src/features/chat/test/ui/ChatBubble.stories.tsx b/src/features/chat/test/ui/ChatBubble.stories.tsx new file mode 100644 index 00000000..42420846 --- /dev/null +++ b/src/features/chat/test/ui/ChatBubble.stories.tsx @@ -0,0 +1,180 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, fn, userEvent, within } from 'storybook/test' + +import { ChatBubble } from '@/features/chat/ui/ChatBubble' +import type { ChatMessage } from '@/features/chat/types/chat' + +function message(overrides: Partial = {}): ChatMessage { + return { + id: 1, + senderId: 200, + senderScope: 'MANAGER', + senderName: '최민석 점주님', + senderProfileImageUrl: null, + content: '근무표 확정해서 공유드려요', + createdAt: '2026-08-19T09:10:00', + isMine: false, + status: 'sent', + messageType: 'NORMAL', + attachments: [], + ...overrides, + } +} + +/** 본문 텍스트는 span 이고 배경·색상은 감싸는 말풍선에 있습니다 */ +function bubbleOf(textNode: HTMLElement): HTMLElement { + return textNode.parentElement as HTMLElement +} + +const meta = { + title: 'features/chat/ChatBubble', + component: ChatBubble, + parameters: { layout: 'centered' }, + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { message: message() }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Received: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const text = canvas.getByText('근무표 확정해서 공유드려요') + + await expect(text).toBeVisible() + await expect(canvas.getByText('오전 9:10')).toBeVisible() + + // 받은 말풍선은 흰색 + const style = window.getComputedStyle(bubbleOf(text)) + await expect(style.backgroundColor).toBe('rgb(255, 255, 255)') + }, +} + +export const Sent: Story = { + args: { + message: message({ + isMine: true, + content: '확인했습니다!', + senderName: '', + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const text = canvas.getByText('확인했습니다!') + + // 보낸 말풍선은 브랜드 그린(main #07c079) + 흰 글자 + const style = window.getComputedStyle(bubbleOf(text)) + await expect(style.backgroundColor).toBe('rgb(7, 192, 121)') + await expect(style.color).toBe('rgb(255, 255, 255)') + }, +} + +export const GroupWithSenderMeta: Story = { + args: { message: message(), showSenderMeta: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // 전체 채팅에서는 발신자 이름과 아바타를 함께 노출합니다 + await expect(canvas.getByText('최민석 점주님')).toBeVisible() + await expect( + canvasElement.querySelector('img[alt="최민석 점주님"]') + ).not.toBeNull() + }, +} + +export const Pending: Story = { + args: { + message: message({ + isMine: true, + content: '전송 중인 메시지', + status: 'pending', + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByText('전송 중')).toBeVisible() + }, +} + +export const Failed: Story = { + args: { + message: message({ + isMine: true, + content: '실패한 메시지', + status: 'failed', + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByText('전송 실패')).toBeVisible() + + // 재전송 핸들러가 없으면 버튼을 걸지 않습니다 + await expect( + canvas.queryByRole('button', { name: '다시 보내기' }) + ).toBeNull() + }, +} + +/** 낙관적 메시지(clientId 보유)만 재전송할 수 있습니다 */ +export const FailedWithRetry: Story = { + args: { + message: message({ + isMine: true, + clientId: 'pending-1', + content: '실패한 메시지', + status: 'failed', + }), + onRetry: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement) + + const retry = canvas.getByRole('button', { name: '다시 보내기' }) + await userEvent.click(retry) + + await expect(args.onRetry).toHaveBeenCalledOnce() + }, +} + +/** 공지는 매니저만 발행할 수 있고, 목록에서 눈에 띄게 구분됩니다 */ +export const Notice: Story = { + args: { + message: message({ + messageType: 'NOTICE', + content: '이번 주 재고 조사는 금요일 마감 후 진행합니다.', + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByText('공지')).toBeVisible() + await expect( + canvas.getByText('이번 주 재고 조사는 금요일 마감 후 진행합니다.') + ).toBeVisible() + }, +} + +const TRANSPARENT_PNG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/6X8AAAAAAAASUVORK5CYII=' + +/** content 가 null 인 이미지 전용 메시지 — 빈 말풍선으로 보이지 않아야 합니다 */ +export const ImageOnly: Story = { + args: { + message: message({ + content: '', + attachments: [{ fileId: 'f1', url: TRANSPARENT_PNG }], + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByAltText('첨부 이미지')).toBeVisible() + }, +} diff --git a/src/features/chat/test/ui/ChatConnectionBanner.stories.tsx b/src/features/chat/test/ui/ChatConnectionBanner.stories.tsx new file mode 100644 index 00000000..bb9e6d36 --- /dev/null +++ b/src/features/chat/test/ui/ChatConnectionBanner.stories.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, within } from 'storybook/test' + +import { ChatConnectionBanner } from '@/features/chat/ui/ChatConnectionBanner' +import type { ChatConnectionState } from '@/features/chat/types/chat' + +/** 연결 상태 전이를 눌러서 확인하기 위한 래퍼 */ +function ConnectionBannerHarness({ + initialState, +}: { + initialState: ChatConnectionState +}) { + const [state, setState] = useState(initialState) + + return ( +
+ + +
+ ) +} + +const meta = { + title: 'features/chat/ChatConnectionBanner', + component: ConnectionBannerHarness, + parameters: { layout: 'centered' }, + args: { initialState: 'reconnecting' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Reconnecting: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect( + canvas.getByText('연결이 끊겼어요 · 다시 연결 중…') + ).toBeVisible() + }, +} + +export const ReconnectedNotice: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await userEvent.click(canvas.getByRole('button', { name: '연결 복구' })) + + // 끊김 이후 연결됐을 때만 복구 안내를 띄웁니다 + await expect(canvas.getByText('다시 연결됐어요')).toBeVisible() + }, +} + +export const ConnectedShowsNothing: Story = { + args: { initialState: 'connected' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.queryByRole('status')).toBeNull() + }, +} diff --git a/src/features/chat/test/ui/ChatRoomRow.stories.tsx b/src/features/chat/test/ui/ChatRoomRow.stories.tsx new file mode 100644 index 00000000..c0d8ba50 --- /dev/null +++ b/src/features/chat/test/ui/ChatRoomRow.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' + +import { ChatRoomRow } from '@/features/chat/ui/ChatRoomListItem' +import type { ChatRoomListItem } from '@/features/chat/types/chat' + +function room(overrides: Partial = {}): ChatRoomListItem { + return { + id: 1, + segment: 'personal', + title: '이서준', + profileImageUrl: null, + latestMessage: '혹시 저 대타 부탁드려도 될까요??', + updatedAt: new Date().toISOString(), + unreadCount: 0, + // 서버는 DIRECT 방에도 활성 멤버 수(2)를 내려줍니다 + memberCount: 2, + opponentId: 200, + opponentScope: 'USER', + ...overrides, + } +} + +const meta = { + title: 'features/chat/ChatRoomRow', + component: ChatRoomRow, + parameters: { layout: 'centered' }, + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { room: room() }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Read: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByText('이서준')).toBeVisible() + await expect(canvas.queryByLabelText(/읽지 않은 메시지/)).toBeNull() + + // 개인 채팅은 인원수(2)를 받아도 제목 옆에 표기하지 않습니다 + await expect(canvas.queryByText('2')).toBeNull() + + // 읽은 방의 미리보기는 regular 400 + const preview = canvas.getByText('혹시 저 대타 부탁드려도 될까요??') + await expect(window.getComputedStyle(preview).fontWeight).toBe('400') + }, +} + +export const Unread: Story = { + args: { room: room({ unreadCount: 12 }) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByText('12')).toBeVisible() + + // 안읽음이 있으면 미리보기를 굵게 표기합니다 + const preview = canvas.getByText('혹시 저 대타 부탁드려도 될까요??') + await expect(window.getComputedStyle(preview).fontWeight).toBe('600') + }, +} + +export const GroupRoom: Story = { + args: { + room: room({ + segment: 'group', + title: '알터 강남점', + memberCount: 7, + latestMessage: '근무표 확정해서 공유드려요', + unreadCount: 1, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByText('알터 강남점')).toBeVisible() + await expect(canvas.getByText('7')).toBeVisible() + }, +} + +export const EmptyConversation: Story = { + args: { room: room({ latestMessage: '' }) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByText('대화를 시작해보세요')).toBeVisible() + }, +} diff --git a/src/features/chat/test/ui/ChatSegmentTab.stories.tsx b/src/features/chat/test/ui/ChatSegmentTab.stories.tsx new file mode 100644 index 00000000..0cf7be5d --- /dev/null +++ b/src/features/chat/test/ui/ChatSegmentTab.stories.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, within } from 'storybook/test' + +import { ChatSegmentTab } from '@/features/chat/ui/ChatSegmentTab' +import type { ChatSegment } from '@/features/chat/types/chat' + +function InteractiveChatSegmentTab({ + unreadCountBySegment, +}: { + unreadCountBySegment?: Partial> +}) { + const [segment, setSegment] = useState('personal') + + return ( +
+ +
+ ) +} + +const meta = { + title: 'features/chat/ChatSegmentTab', + component: InteractiveChatSegmentTab, + parameters: { layout: 'centered' }, + args: { unreadCountBySegment: { personal: 3, group: 1 } }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const PersonalActiveByDefault: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + const personal = canvas.getByRole('tab', { name: /개인 채팅/ }) + const group = canvas.getByRole('tab', { name: /전체 채팅/ }) + + await expect(personal).toHaveAttribute('aria-selected', 'true') + await expect(group).toHaveAttribute('aria-selected', 'false') + + // 세그먼트별 미읽음 합계를 각각 표기합니다 + await expect(canvas.getByText('3')).toBeVisible() + await expect(canvas.getByText('1')).toBeVisible() + }, +} + +export const SwitchToGroup: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const group = canvas.getByRole('tab', { name: /전체 채팅/ }) + + await userEvent.click(group) + + await expect(group).toHaveAttribute('aria-selected', 'true') + await expect( + canvas.getByRole('tab', { name: /개인 채팅/ }) + ).toHaveAttribute('aria-selected', 'false') + }, +} + +export const NoUnreadHidesBadge: Story = { + args: { unreadCountBySegment: { personal: 0, group: 0 } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.queryByLabelText(/읽지 않은 메시지/)).toBeNull() + }, +} + +export const OverNinetyNine: Story = { + args: { unreadCountBySegment: { personal: 128, group: 0 } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect(canvas.getByText('99+')).toBeVisible() + }, +} diff --git a/src/features/chat/test/ui/MessageInput.stories.tsx b/src/features/chat/test/ui/MessageInput.stories.tsx new file mode 100644 index 00000000..09db3940 --- /dev/null +++ b/src/features/chat/test/ui/MessageInput.stories.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, within } from 'storybook/test' + +import { MessageInput } from '@/features/chat/ui/MessageInput' +import { CHAT_MESSAGE_MAX_LENGTH } from '@/features/chat/types/chat' + +/** 실제 입력·전송 동작을 확인하기 위한 상태 보유 래퍼 */ +function InteractiveMessageInput({ + initialValue = '', +}: { + initialValue?: string +}) { + const [value, setValue] = useState(initialValue) + const [sent, setSent] = useState([]) + + return ( +
+
    + {sent.map((message, index) => ( +
  • {message}
  • + ))} +
+ { + setSent(previous => [...previous, value]) + setValue('') + }} + /> +
+ ) +} + +const meta = { + title: 'features/chat/MessageInput', + component: InteractiveMessageInput, + parameters: { layout: 'centered' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const EmptyDisablesSend: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // 빈 값에서는 전송 버튼이 비활성화됩니다 + await expect(canvas.getByRole('button', { name: '전송' })).toBeDisabled() + }, +} + +export const WhitespaceDisablesSend: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await userEvent.type(canvas.getByLabelText('메시지 입력'), ' ') + await expect(canvas.getByRole('button', { name: '전송' })).toBeDisabled() + }, +} + +export const TypeAndSend: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const input = canvas.getByLabelText('메시지 입력') + const sendButton = canvas.getByRole('button', { name: '전송' }) + + await userEvent.type(input, '안녕하세요!') + await expect(sendButton).toBeEnabled() + + await userEvent.click(sendButton) + + await expect(canvas.getByText('안녕하세요!')).toBeVisible() + await expect(input).toHaveValue('') + await expect(sendButton).toBeDisabled() + }, +} + +export const AtCharacterLimit: Story = { + args: { initialValue: 'ㄱ'.repeat(CHAT_MESSAGE_MAX_LENGTH) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await expect( + canvas.getByText( + `최대 ${CHAT_MESSAGE_MAX_LENGTH}자까지 입력할 수 있어요.` + ) + ).toBeVisible() + await expect(canvas.getByLabelText('메시지 입력')).toHaveValue( + 'ㄱ'.repeat(CHAT_MESSAGE_MAX_LENGTH) + ) + }, +} diff --git a/src/features/chat/types/chat.ts b/src/features/chat/types/chat.ts new file mode 100644 index 00000000..c72a990f --- /dev/null +++ b/src/features/chat/types/chat.ts @@ -0,0 +1,114 @@ +import type { + ChatAttachmentDto, + ChatMessageType, + ChatParticipantScope, +} from '@/features/chat/types/dto' + +export type { ChatParticipantScope, ChatMessageType } + +/** 이미지 첨부 — 낙관적 렌더 중에는 `url` 이 로컬 object URL 입니다 */ +export type ChatAttachment = ChatAttachmentDto + +/** 메시지 한 건에 붙일 수 있는 이미지 수 — 초과분은 서버가 거부합니다 */ +export const CHAT_ATTACHMENT_MAX_COUNT = 10 + +/** 채팅 탭 세그먼트 — 개인(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 + +/** 목록 행·방 상세가 공유하는 방 표현 — 두 API 가 같은 필드셋을 내려줍니다 */ +export interface ChatRoomSummary { + id: number + segment: ChatSegment + /** 서버 `roomName` — 개인=상대 이름 · 전체=업장 이름 */ + title: string + profileImageUrl: string | null + /** 활성 멤버 수 — 서버가 주지 않으면 undefined (헤더·목록은 전체 채팅에서만 노출) */ + memberCount?: number + /** 전체 채팅방에는 상대방 개념이 없어 undefined */ + opponentId?: number + opponentScope?: ChatParticipantScope +} + +/** 채팅 목록 행 */ +export interface ChatRoomListItem extends ChatRoomSummary { + latestMessage: string + /** 정렬·상대 시각 표기 기준 */ + updatedAt: string + unreadCount: number +} + +/** 딥링크로 방에 바로 진입해 목록 캐시가 없을 때 헤더·발신자 판별을 채웁니다 */ +export type ChatRoomDetail = ChatRoomSummary + +/** 낙관적 전송 상태 — 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 + /** 전체 채팅 헤더의 "멤버 N" 표기 — 개인 채팅에서는 쓰지 않습니다 */ + 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 00000000..06d80858 --- /dev/null +++ b/src/features/chat/types/dto.ts @@ -0,0 +1,116 @@ +/** + * 채팅 서버 응답 shape — USER(`/app/chat/*`) · MANAGER(`/manager/chat/*`) 공통. + * 아직 서버가 주지 않는 필드(방별 미읽음)와 구 배포본에 없는 필드(방 타입·표시명·인원수)는 + * optional 로 두고 클라이언트에서 폴백합니다. + */ + +/** 클라이언트 도메인 표현 — 서버의 APP 을 USER 로 정규화해 씁니다 */ +export type ChatParticipantScope = 'USER' | 'MANAGER' + +/** 서버 enum — 알바생은 USER 가 아니라 APP */ +export type ChatServerScope = 'APP' | 'MANAGER' + +/** 서버 enum 직렬화 — `{ value, description }` 객체가 표준이고 평문도 올 수 있습니다 */ +export type DescribedEnumDto = + | T + | { value: T; description?: string } + +/** + * scope 직렬화가 엔드포인트마다 다릅니다. + * 방 목록은 평문 `"APP"`, 방 상세·메시지는 `{ value, description }` 객체로 내려옵니다. + */ +export type ChatScopeDto = DescribedEnumDto + +/** DIRECT=1:1, GROUP=업장 단위 단체방 */ +export type ChatRoomTypeValue = 'DIRECT' | 'GROUP' + +export type ChatRoomTypeDto = DescribedEnumDto + +export type ChatMessageType = 'NORMAL' | 'NOTICE' + +export interface ChatAttachmentDto { + fileId: string + url: string +} + +/** + * 목록·정보 응답의 공통 필드셋. + * GROUP 방은 상대방 개념이 없어 `opponentXxx` 가 모두 null 로 내려옵니다. + */ +interface ChatRoomBaseDto { + id: number + /** 구 배포본에는 없어 optional — 없으면 상대방 유무로 추론합니다 */ + type?: ChatRoomTypeDto + /** 방 표시명 — GROUP=업장명, DIRECT=상대방 이름 */ + roomName?: string | null + /** 활성 멤버 수 — DIRECT 도 실제값(2)이 내려옵니다 */ + memberCount?: number + opponentId: number | null + opponentScope: ChatScopeDto | null + opponentName: string | null + /** presigned URL — 없으면 Avatar 기본 프로필로 폴백 */ + opponentProfileImageUrl?: string | null + createdAt: string + updatedAt: string +} + +/** GET /{app|manager}/chat/rooms */ +export interface ChatRoomListItemDto extends ChatRoomBaseDto { + latestMessageContent: string | null + /** + * 목록 응답에는 아직 없는 필드입니다 — 없으면 0으로 간주합니다. + * 서버가 채워주기 전까지 목록 뱃지·Docbar 뱃지는 항상 0으로 보입니다. + */ + unreadCount?: number +} + +/** GET /{app|manager}/chat/rooms/{chatRoomId} — 딥링크 진입 시 헤더 정보 */ +export type ChatRoomDetailDto = ChatRoomBaseDto + +/** 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 + /** 브로드캐스트 payload 에는 없어 클라이언트가 senderId·scope 로 판별합니다 */ + isMine?: boolean + /** 이 메시지를 아직 읽지 않은 멤버 수. 브로드캐스트 payload 에는 없습니다 */ + unreadCount?: number + attachments?: ChatAttachmentDto[] + /** 단체방 발신자 표기용 — 조회·브로드캐스트 양쪽 모두 내려줍니다 */ + 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/features/chat/ui/AttachmentTray.tsx b/src/features/chat/ui/AttachmentTray.tsx new file mode 100644 index 00000000..bb6b11db --- /dev/null +++ b/src/features/chat/ui/AttachmentTray.tsx @@ -0,0 +1,96 @@ +import { useRef, type ChangeEvent } from 'react' +import cameraIcon from '@/assets/icons/camera.svg' +import imageIcon from '@/assets/icons/image.svg' + +interface AttachmentTrayProps { + /** 선택한 이미지를 업로드해 전송합니다 */ + onSelectImages?: (files: File[]) => void + /** 업로드·전송이 끝날 때까지 재선택을 막습니다 */ + isSending?: boolean +} + +export function AttachmentTray({ + onSelectImages, + isSending = false, +}: AttachmentTrayProps) { + const galleryInputRef = useRef(null) + const cameraInputRef = useRef(null) + + const handleChange = (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []) + // 같은 파일을 연속으로 고를 수 있도록 값을 비웁니다 + event.target.value = '' + if (files.length > 0) onSelectImages?.(files) + } + + const isDisabled = isSending || !onSelectImages + + return ( +
+
+ + + +
+ + {isSending ? ( +

+ 사진을 보내는 중이에요… +

+ ) : null} + + + {/* capture 는 모바일에서 카메라를 바로 띄웁니다(데스크톱은 파일 선택으로 폴백) */} + +
+ ) +} diff --git a/src/features/chat/ui/ChatBubble.tsx b/src/features/chat/ui/ChatBubble.tsx new file mode 100644 index 00000000..15d8527f --- /dev/null +++ b/src/features/chat/ui/ChatBubble.tsx @@ -0,0 +1,117 @@ +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 + /** 전송 실패한 메시지 재전송 — 없으면 실패 표시만 합니다 */ + onRetry?: () => void +} + +const AVATAR_SIZE = 36 + +export function ChatBubble({ + message, + showSenderMeta = false, + reserveAvatarSpace = false, + onRetry, +}: 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 ? ( + + 전송 실패 + {onRetry ? ( + + ) : null} + + ) : ( + + {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 00000000..1fdaa76f --- /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 00000000..c06aaedc --- /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 00000000..e11407a7 --- /dev/null +++ b/src/features/chat/ui/ChatRoomListItem.tsx @@ -0,0 +1,166 @@ +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 + // 서버는 개인 채팅에도 인원수(2)를 주지만 표기는 전체 채팅에서만 의미가 있습니다 + const memberCount = room.segment === 'group' ? room.memberCount : undefined + + return ( + + ) +} + +interface SwipeableChatRoomItemProps extends ChatRoomRowProps { + /** + * 삭제 핸들러가 없으면 스와이프 자체를 막고 평범한 행으로 렌더합니다. + * 서버에 방 삭제·나가기 API 가 없어, 열어봐야 눌리지 않는 버튼만 드러납니다. + */ + onDelete?: () => void +} + +export function SwipeableChatRoomItem({ + room, + onClick, + onDelete, +}: SwipeableChatRoomItemProps) { + if (!onDelete) { + return + } + + return +} + +interface SwipeToDeleteRowProps extends ChatRoomRowProps { + onDelete: () => void +} + +function SwipeToDeleteRow({ room, onClick, onDelete }: SwipeToDeleteRowProps) { + 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 00000000..022c9e7d --- /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 00000000..3c08aeca --- /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 00000000..1eac08ea --- /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 00000000..344c9f0d --- /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 ( +
+
+ + +
+