From 726f9f854f916ca9da87656245b175709beb448c Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 21:56:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EB=AA=A9=EB=A1=9D=EC=9D=84?= =?UTF-8?q?=20=EB=8C=80=ED=99=94=EB=A1=9C=20=EB=AC=B6=EC=96=B4=20=EB=B3=B4?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4=20(14=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=203=EC=B0=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메일함 목록을 대화 한 줄로 묶어 보는 [대화로 묶기]와 설정 › 보내기 · 읽기의 한 줄을 더한다. 켜면 목록 출처가 GET /api/conversations 이고, 줄에 하는 일 (고르기 · 읽음 · 중요 · 이동 · 삭제)은 그 줄의 messageIds 에서 붙잡힌 메일을 뺀 메일을 지금의 여러 통 API 로 보낸다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(9단계). - 대화 목록에서는 messages 가 비므로 읽기 화면 · 삭제 · 예약 취소가 줄 기준 도우미(rowCount · rowIndexOfMessage · isListedMessage 등)로 같은 규칙을 탄다 - 첫 진입은 이 브라우저가 그 직원에게서 마지막으로 안 값으로 열고, 설정을 받지 못해도 [대화로 묶기]를 쓸 수 있다 - 리뷰 M1: 로그아웃 · 세션 만료 직후 설정 reset 이 watcher 를 깨워 떠나는 화면의 목록을 다시 부르던 회귀 — canUseApp 이 false 면 부르지 않고 reset 은 보이는 값을 뒤집지 않는다 - 리뷰 L7: 버린 목록 요청은 AbortController 로 끊는다(엔진 방식은 한 요청이 15초까지) Co-Authored-By: Claude Opus 5.5 --- .../components/mail/ConversationListItem.vue | 333 ++++++++++ .../components/mail/ConversationTableRow.vue | 287 +++++++++ frontend/src/components/mail/ListReadNav.vue | 30 +- .../mail/MailConversationToggle.vue | 80 +++ .../src/components/mail/MailListFrame.vue | 9 +- .../src/components/mail/MailListHeader.vue | 65 +- .../components/mail/MailSelectionToolbar.vue | 9 +- .../components/mail/MessageConversation.vue | 2 +- frontend/src/components/mail/MessageList.vue | 53 +- .../src/components/mail/MessageMoreMenu.vue | 2 +- .../src/components/mail/MessageTable.spec.ts | 4 +- frontend/src/components/mail/MessageTable.vue | 80 ++- .../src/components/mail/MessageTableHead.vue | 8 +- .../src/composables/useBulkMailActions.ts | 12 +- .../src/composables/useConversationRows.ts | 61 ++ frontend/src/composables/useMail.ts | 21 + frontend/src/composables/useMailSettings.ts | 27 +- frontend/src/composables/useMessageRemoval.ts | 3 +- .../src/composables/usePendingSendRefresh.ts | 3 +- .../composables/useScheduledMessageActions.ts | 2 +- .../pages/mail/ComposePage.sendlater.spec.ts | 2 +- .../pages/mail/MailboxPage.threads.spec.ts | 585 ++++++++++++++++++ frontend/src/pages/mail/MailboxPage.vue | 17 +- .../pages/settings/MailSendReadPage.spec.ts | 35 +- .../src/pages/settings/MailSendReadPage.vue | 52 +- frontend/src/shared/constants/icons.ts | 3 + .../src/shared/constants/mail-conversation.ts | 23 + .../src/shared/constants/mail-settings.ts | 10 + frontend/src/shared/constants/mail.ts | 13 + frontend/src/shared/types/mail.ts | 45 +- .../utils/mail-conversation-list.spec.ts | 327 ++++++++++ .../shared/utils/mail-conversation-list.ts | 337 ++++++++++ .../src/shared/utils/mail-conversation.ts | 15 +- .../src/shared/utils/mail-list-source.spec.ts | 28 + frontend/src/shared/utils/mail-list-source.ts | 40 +- .../src/stores/mail-settings.store.spec.ts | 149 +++++ frontend/src/stores/mail-settings.store.ts | 130 +++- .../src/stores/mail.store.threads.spec.ts | 541 ++++++++++++++++ frontend/src/stores/mail.store.ts | 16 +- frontend/src/stores/mail/bulk.ts | 106 +++- frontend/src/stores/mail/conversation.ts | 2 +- frontend/src/stores/mail/list.ts | 303 ++++++++- frontend/src/stores/mail/message.ts | 17 +- frontend/src/stores/mail/scheduled.ts | 11 +- frontend/src/stores/mail/selection.ts | 64 +- frontend/src/styles/tokens.css | 3 +- 46 files changed, 3782 insertions(+), 183 deletions(-) create mode 100644 frontend/src/components/mail/ConversationListItem.vue create mode 100644 frontend/src/components/mail/ConversationTableRow.vue create mode 100644 frontend/src/components/mail/MailConversationToggle.vue create mode 100644 frontend/src/composables/useConversationRows.ts create mode 100644 frontend/src/pages/mail/MailboxPage.threads.spec.ts create mode 100644 frontend/src/shared/utils/mail-conversation-list.spec.ts create mode 100644 frontend/src/shared/utils/mail-conversation-list.ts create mode 100644 frontend/src/stores/mail-settings.store.spec.ts create mode 100644 frontend/src/stores/mail.store.threads.spec.ts diff --git a/frontend/src/components/mail/ConversationListItem.vue b/frontend/src/components/mail/ConversationListItem.vue new file mode 100644 index 0000000..8dcdaed --- /dev/null +++ b/frontend/src/components/mail/ConversationListItem.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/frontend/src/components/mail/ConversationTableRow.vue b/frontend/src/components/mail/ConversationTableRow.vue new file mode 100644 index 0000000..1c45e3c --- /dev/null +++ b/frontend/src/components/mail/ConversationTableRow.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/frontend/src/components/mail/ListReadNav.vue b/frontend/src/components/mail/ListReadNav.vue index 599abe3..0ef43bb 100644 --- a/frontend/src/components/mail/ListReadNav.vue +++ b/frontend/src/components/mail/ListReadNav.vue @@ -2,6 +2,8 @@ // 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead) // 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯). // 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다. +// 대화로 묶은 메일함(14단계)은 줄(대화) 사이를 옮긴다 — 이전/다음은 앞뒤 줄의 마지막 메일, 위치는 "n번째 대화 / 대화 수". +// 연 메일이 줄의 마지막 메일이 아니어도([이 메일만 열기]로 연 더 오래된 메일) 그 메일이 든 줄을 기준으로 한다 import { computed, ref } from 'vue' import { useRouter } from 'vue-router' @@ -32,16 +34,18 @@ const listHref = computed(() => { const source = mailStore.listSource return source ? router.resolve(listLocationOf(source)).href : router.resolve({ name: 'mail' }).href }) -/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */ -const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId)) +/** 불러온 목록에서의 위치(줄) — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */ +const index = computed(() => mailStore.rowIndexOfMessage(props.messageId)) /** * 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수. - * 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다 + * 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다. + * 대화 목록은 대화 수 — 모르면 다 받았을 때만 불러온 줄 수 (메일함의 통 수는 대화 수가 아니다) */ const total = computed(() => { const known = mailStore.pageInfo?.total ?? null if (known !== null) return known if (mailStore.search) return null + if (mailStore.isConversationList) return mailStore.hasMore ? null : mailStore.rowCount return mailStore.currentMailbox?.totalCount ?? mailStore.messages.length }) const position = computed(() => @@ -51,18 +55,17 @@ const hasPrev = computed(() => index.value > 0) /** 불러온 범위의 끝이어도 서버에 더 있거나(hasNext) 뒤에서 이어 받는 중이면 다음으로 갈 수 있다 */ const hasNext = computed( () => - index.value !== -1 && - (index.value < mailStore.messages.length - 1 || mailStore.hasMore || mailStore.isLoadingMore), + index.value !== -1 && (index.value < mailStore.rowCount - 1 || mailStore.hasMore || mailStore.isLoadingMore), ) /** 다음 메일을 받아야 하는데 목록을 보이는 채로 새로 고치는 중 — 끝날 때까지 [다음]을 막는다 (옛 커서로 붙이지 않게) */ const nextWaiting = computed( - () => index.value !== -1 && !mailStore.messages[index.value + 1] && mailStore.isRefreshing, + () => index.value !== -1 && mailStore.rowMessageIdAt(index.value + 1) === null && mailStore.isRefreshing, ) async function goPrev() { - const previous = mailStore.messages[index.value - 1] + const previous = mailStore.rowMessageIdAt(index.value - 1) const source = mailStore.listSource - if (previous && source) await showSiblingIn(source, previous.id) + if (previous && source) await showSiblingIn(source, previous) } /** @@ -72,19 +75,19 @@ async function goPrev() { async function goNext() { if (loadingNext.value || nextWaiting.value || index.value === -1) return const current = index.value - if (!mailStore.messages[current + 1] && (mailStore.hasMore || mailStore.isLoadingMore)) { + if (mailStore.rowMessageIdAt(current + 1) === null && (mailStore.hasMore || mailStore.isLoadingMore)) { loadingNext.value = true try { const waited = mailStore.isLoadingMore await mailStore.loadMore() - if (waited && !mailStore.messages[current + 1] && mailStore.hasMore) await mailStore.loadMore() + if (waited && mailStore.rowMessageIdAt(current + 1) === null && mailStore.hasMore) await mailStore.loadMore() } finally { loadingNext.value = false } } - const next = mailStore.messages[current + 1] + const next = mailStore.rowMessageIdAt(current + 1) const source = mailStore.listSource - if (next && source) await showSiblingIn(source, next.id) + if (next && source) await showSiblingIn(source, next) } /** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */ @@ -94,7 +97,8 @@ function onBackToList(event: MouseEvent) { event.preventDefault() const source = mailStore.listSource if (!source) return - viewStore.setReturnFocus(props.messageId) + // 대화 목록은 줄의 링크가 마지막 메일을 가리킨다 — 그 줄로 초점을 돌려준다 + viewStore.setReturnFocus(mailStore.rowMessageIdAt(index.value) ?? props.messageId) void backToSourceList(source) } diff --git a/frontend/src/components/mail/MailConversationToggle.vue b/frontend/src/components/mail/MailConversationToggle.vue new file mode 100644 index 0000000..8c17917 --- /dev/null +++ b/frontend/src/components/mail/MailConversationToggle.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/components/mail/MailListFrame.vue b/frontend/src/components/mail/MailListFrame.vue index 024affb..1646e7b 100644 --- a/frontend/src/components/mail/MailListFrame.vue +++ b/frontend/src/components/mail/MailListFrame.vue @@ -8,6 +8,7 @@ // 결과 없음은 더 찾을 것이 없을 때만 — 이어 찾을 것이 남았는데 아직 하나도 못 찾았으면 [더 찾기](MailSearchMore) // - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다) // - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다 +// - 대화로 묶은 메일함(14단계)도 같은 틀이다 — 줄 수 · 빈 상태는 메일 목록과 같은 규칙(줄이 대화일 뿐) import { computed, nextTick, ref, watch } from 'vue' import AppIcon from '@/components/AppIcon.vue' @@ -75,7 +76,7 @@ const retentionNotice = computed(() => { const role = mailbox.value?.role ?? null const text = role ? (MAILBOX_RETENTION_NOTICES[role] ?? null) : null if (text === null) return null - return mailStore.listStatus === 'ready' && mailStore.messages.length === 0 ? null : text + return mailStore.listStatus === 'ready' && mailStore.rowCount === 0 ? null : text }) /** 검색 조건 — 조건 줄은 걸렸을 때만 (14단계) */ const searchConditions = computed(() => @@ -97,15 +98,17 @@ const singleChar = computed( const loadingMore = computed(() => mailStore.isLoadingMore || mailStore.isRefreshing) /** 결과가 아직 없지만 이어 찾을 것이 남았다 — 결과 없음 대신 [더 찾기] */ const searchPending = computed( - () => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.messages.length === 0 && mailStore.hasMore, + () => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.rowCount === 0 && mailStore.hasMore, ) /** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */ const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로')) function retry() { const current = search.value + const source = mailStore.listSource if (current) void mailStore.openSearch(current.q, current.scope, { force: true, conditions: current.conditions }) else if (scheduled.value) void mailStore.openScheduled() + else if (source?.kind === 'conversations') void mailStore.openConversations(source.mailboxId) else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId) } @@ -240,7 +243,7 @@ watch( @leave="leave" /> -