feat(frontend): 목록을 대화로 묶어 보게 한다 (14단계 화면 3차)

메일함 목록을 대화 한 줄로 묶어 보는 [대화로 묶기]와 설정 › 보내기 · 읽기의
한 줄을 더한다. 켜면 목록 출처가 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 21:56:13 +09:00
parent 1a4f1d3b56
commit 726f9f854f
46 changed files with 3782 additions and 183 deletions
@@ -13,6 +13,7 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때
export function useBulkMailActions() {
const mailStore = useMailStore()
@@ -22,10 +23,11 @@ export function useBulkMailActions() {
/** 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다 (읽음 · 중요 표시는 막지 않는다) */
const busy = ref<boolean>(false)
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 */
const allFlagged = computed<boolean>(
() => mailStore.checkedMessages.length > 0 && mailStore.checkedMessages.every((message) => message.isFlagged),
)
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 (대화 목록은 고른 줄이 모두 중요 표시가 있을 때) */
const allFlagged = computed<boolean>(() => {
const rows = mailStore.isConversationList ? mailStore.checkedConversations : mailStore.checkedMessages
return rows.length > 0 && rows.every((row) => row.isFlagged)
})
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
/** 스팸메일함을 보는 중 — 「스팸 신고」가 「스팸 아님」이 된다 (스팸메일함 안의 검색 포함) */
@@ -56,7 +58,7 @@ export function useBulkMailActions() {
const openId = mailStore.selectedId
const source = mailStore.listSource
if (result.stale || !openId || !source || !result.doneIds.includes(openId)) return
if (mailStore.messages.some((message) => message.id === openId)) return
if (mailStore.isListedMessage(openId)) return
await router.replace(listLocationOf(source))
}
@@ -0,0 +1,61 @@
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useToast } from './useToast'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import type { ConversationSummary, MailboxRole } from '@/shared/types/mail'
import { conversationWho, isConversationHeld } from '@/shared/utils/mail-conversation-list'
import { isDraftRole, isRowUnread, type RowWho } from '@/shared/utils/mail-list-row'
import { messageLocationOf } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
/**
* 대화로 묶은 메일함의 줄마다 필요한 값 (14단계 — 디자인 원본 ListThreads14) — 분할 보기 줄(ConversationListItem)과 목록 보기 표(ConversationTableRow)가 함께 쓴다.
* - 이름 자리: 참여자(내 주소는 "나" — 끝에), 보낸편지함 · 임시보관함에서 나뿐이면 "받는 사람: …" (mail-conversation-list.ts 의 conversationWho)
* - 안 읽음: 하나라도 안 읽었으면 굵게 — 임시보관함은 메일 목록처럼 굵게 하지 않는다
* - 누르면 갈 곳: 그 메일함의 마지막 메일 — 임시보관함이면 쓰기 화면에서 이어 쓴다(메일 목록과 같다)
* - 열린 메일이 든 줄: 마지막 메일이거나 줄에 든 메일([이 메일만 열기]로 연 더 오래된 메일)
* - 별: 줄의 메일 모두(붙잡힌 메일은 빼고 — 스토어). 줄의 중요 표시가 붙잡힌 메일에만 있어 풀리지 않았으면 그 까닭을 알린다
*/
export function useConversationRows() {
const mailStore = useMailStore()
const authStore = useAuthStore()
const toast = useToast()
/** 목록의 메일함 역할 — 묶은 목록은 늘 메일함 하나다 */
const role = computed<MailboxRole | null>(() => mailStore.currentMailbox?.role ?? null)
/** 임시보관함 — 제목 없는 저장본을 흐린 글자로 */
const isDraft = computed<boolean>(() => isDraftRole(role.value))
function whoOf(row: ConversationSummary, view: 'split' | 'table'): RowWho {
return conversationWho(row, { myEmail: authStore.user?.email, role: role.value, view })
}
function isUnread(row: ConversationSummary): boolean {
return isRowUnread({ isRead: row.isRead }, role.value)
}
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
function isHeld(row: ConversationSummary): boolean {
return isConversationHeld(row)
}
function locationOf(row: ConversationSummary): RouteLocationRaw {
if (isDraftRole(role.value)) return { name: 'mail-compose', query: { draft: row.latest.id } }
const source = mailStore.listSource
return source ? messageLocationOf(source, row.latest.id) : { name: 'mail' }
}
function isSelected(row: ConversationSummary): boolean {
const id = mailStore.selectedId
return id !== null && (row.latest.id === id || row.messageIds.includes(id))
}
async function toggleFlag(rowId: string): Promise<void> {
const { heldFlagged } = await mailStore.toggleConversationFlag(rowId)
if (heldFlagged) toast.error(MAIL_CONVERSATION_LIST_TEXT.heldFlagNote)
}
return { role, isDraft, whoOf, isUnread, isHeld, locationOf, isSelected, toggleFlag }
}
+21
View File
@@ -13,6 +13,8 @@ import {
import { MAIL_CONVERSATION_TIMEOUT_MS } from '@/shared/constants/mail-conversation'
import type {
Attachment,
ConversationListPage,
ConversationListQuery,
ConversationPage,
Draft,
DraftPayload,
@@ -52,6 +54,7 @@ import type {
} from '@/shared/types/mail'
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
import { conversationPageOf } from '@/shared/utils/mail-conversation'
import { conversationListPageOf } from '@/shared/utils/mail-conversation-list'
import { pendingSendOf } from '@/shared/utils/pending-send'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
@@ -294,6 +297,23 @@ export function useMail() {
return conversationPageOf(page)
}
/**
* 그 메일함을 대화로 묶은 목록 — 한 줄이 대화 하나, 그 메일함 안 마지막 받은 시각 최신부터 (14단계 — 계약 2절 "대화로 묶어 보기").
* 커서는 그 메일함 · 그 기준에서만 통한다. 엔진 방식은 한 요청이 15초까지 걸릴 수 있어 같은 대화와 같은 시간 한도를 쓴다.
* 응답은 모양을 맞춰 돌려준다 — 모양이 틀린 줄은 빼고, 마지막 메일은 늘 messageIds 에 든다
*/
async function listConversations(
query: ConversationListQuery,
options?: MailRequestOptions,
): Promise<ConversationListPage> {
const page = await api.get<unknown, unknown>('/conversations', {
timeout: MAIL_CONVERSATION_TIMEOUT_MS,
...options,
params: query,
})
return conversationListPageOf(page)
}
function updateMessage(
id: string,
changes: UpdateMessagePayload,
@@ -500,6 +520,7 @@ export function useMail() {
getSearchIndex,
getMessage,
getConversation,
listConversations,
updateMessage,
updateMessages,
deleteMessages,
+21 -6
View File
@@ -9,20 +9,35 @@ export type MailSettingsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'si
/** 경로에 넣는 값은 항상 인코딩한다 — 도메인 차단 "@spam.example" 은 "%40spam.example" */
const segment = (value: string) => encodeURIComponent(value)
/** GET · PATCH /mail-settings 가 실제로 주는 모양 — 14단계 앞의 서버는 groupByConversation 이 없다 */
type MailSettingsReply = Omit<MailSettings, 'groupByConversation'> & { groupByConversation?: unknown }
/** 설정 응답 → 화면의 값. groupByConversation 이 불리언 true 가 아니면 묶지 않는 것으로 본다 (처음 값 — 계약 2절 "보내기 · 읽기 설정") */
function mailSettingsOf(reply: MailSettingsReply): MailSettings {
// 응답을 바꾼 게이트웨이 등으로 객체가 아닐 수도 있다 — 그때도 던지지 않고 묶지 않는 것으로 본다
const value: unknown = reply
const grouped =
typeof value === 'object' && value !== null && (value as { groupByConversation?: unknown }).groupByConversation === true
return { ...reply, groupByConversation: grouped }
}
// 보내기 · 읽기 설정 · 보낸 사람 차단 API Composable — docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" · "보낸 사람 차단" (9단계, 모두 로그인 필요)
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
// 컴포넌트는 이것을 직접 부르지 않고 스토어(mail-settings.store · mail-block.store)를 거친다
export function useMailSettings() {
const api = useApi()
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기) */
function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
return api.get<MailSettings, MailSettings>('/mail-settings', options)
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기 · 묶지 않음) */
async function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
return mailSettingsOf(await api.get<MailSettingsReply, MailSettingsReply>('/mail-settings', options))
}
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체 */
function updateSettings(patch: Partial<MailSettings>, options?: MailSettingsRequestOptions): Promise<MailSettings> {
return api.patch<MailSettings, MailSettings>('/mail-settings', patch, options)
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체. groupByConversation 은 불리언만 받는다(그 밖은 400 VAL_001) */
async function updateSettings(
patch: Partial<MailSettings>,
options?: MailSettingsRequestOptions,
): Promise<MailSettings> {
return mailSettingsOf(await api.patch<MailSettingsReply, MailSettingsReply>('/mail-settings', patch, options))
}
/** 차단한 주소 · 도메인 — 최근에 차단한 것부터 500개까지 */
@@ -21,6 +21,7 @@ import { useMailStore } from '@/stores/mail.store'
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
// - 뺀 뒤(leaveRemoved): 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
// 대화로 묶은 메일함(14단계)은 같은 결로 그 줄의 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄의 마지막 메일 (스토어의 neighborAfterRemoval).
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
export interface MessageRemovalOutcome {
@@ -55,7 +56,7 @@ export function useMessageRemoval() {
}
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
const removed = done && !stale && !mailStore.isListedMessage(id)
return { removed, nextId: removed ? nextId : null }
}
@@ -42,7 +42,8 @@ export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) {
const watches = computed<PendingWatch[]>(() =>
pendingWatchesOf([
...mailStore.messages,
// 보이는 목록의 줄 — 대화로 묶은 메일함은 줄마다 마지막 메일 (14단계 — 줄 안의 다른 붙잡힌 메일은 시각을 몰라 다음 맞추기를 기다린다)
...mailStore.listedSummaries,
mailStore.selected,
mailStore.nextScheduled,
...mailStore.conversationItems,
@@ -74,7 +74,7 @@ export function useScheduledMessageActions() {
/** 목록에서 빠졌는가 — 빠졌으면 삭제처럼 다음 메일(분할 보기) · 목록(목록 보기)으로 */
function removedFromList(messageId: string): boolean {
return !mailStore.messages.some((message) => message.id === messageId)
return !mailStore.isListedMessage(messageId)
}
/** [예약 취소] — 보내지 않고 임시보관함으로 */