feat(frontend): 읽기 화면에 같은 대화 칸을 더한다 (14단계 화면 2차)

- 본문 · 첨부 아래 "같은 대화 N통" — 오래된 차례의 줄(48), 지금 보는 메일 줄은 누를 수 없고,
  다른 줄은 그 자리에서 펼쳐 본문(MailBodyFrame — 강조 쿼리 없이)과 [답장] · [이 메일만 열기]
- 100통씩 받고 쪽이 모자라면 5번까지 이어 묻기 + [더 보기], 통 수를 모르면 수 없이
- 실패는 칸 안 안내 + [다시 시도](알림 없음), 이 경로가 없는 옛 서버(404)면 칸이 없다
- 다시 받기는 메일함 통 수가 바뀔 때 · 쓰기 화면에서 돌아올 때 · 붙잡힌 메일이 풀릴 때 —
  읽음 표시만 바뀐 소식으로는 받지 않고, 끝난 때부터 10초 간격
- 펼친 안 읽은 메일은 읽음으로(읽기 화면과 같은 길), 펼친 본문 안 Ctrl+P 는 그 메일 인쇄

리뷰 반영: 누른 단추가 사라질 때 초점 옮기기, 읽음 소식으로 요청이 두 배가 되던 것,
받는 중 온 소식 · 시계 뒤로 · [더 보기] 뒤 붙잡힌 메일, 받아 둔 본문 10통까지,
본문 안내 줄 · 지워진 메일, 목록 보기에서는 [이 메일만 열기]가 기록을 바꿔 끼운다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 10:15:44 +09:00
parent 37d689e113
commit 8f02814a4f
26 changed files with 3910 additions and 17 deletions
@@ -0,0 +1,285 @@
<script setup lang="ts">
// "같은 대화" 칸에서 펼친 줄의 안 (14단계 — 디자인 원본 Thread14 "하나 펼침")
// 받는 사람 · 제목 한 줄씩, 본문(MailBodyFrame — 검색 쿼리 없이 받은 본문이라 강조가 없다), [답장] · [이 메일만 열기](36 — 밀도 예외 --size-notice-action 재사용).
// - 본문이 다 보이지 않으면(서버의 isHtmlBodyOmitted · isBodyTruncated) 읽기 화면과 같은 안내 줄(MAIL_BODY_NOTICES)을 본문 위에 — 읽기 화면도 같은 본문이다.
// 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다
// - 본문을 받는 동안은 뼈대(aria-busy), 못 받으면 사전 문구 + [다시 시도]. 그사이 지워진 메일(RES_001)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 —
// 스토어가 대화를 다시 받아 이 줄을 뺀다. 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다
// - [답장]은 읽기 화면의 [답장]과 같은 쓰기 화면이고, 임시 저장본 · 예약한 메일에는 없다. [이 메일만 열기]는 그 메일을 읽기 화면으로 연다(mail-conversation.ts) —
// 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다([목록으로]가 쌓인 기록으로 돌아가지 않게)
// - 본문 안에서 누른 Ctrl+P(맥 ⌘P)는 이 메일의 인쇄 화면을 연다(읽기 화면과 같은 판정 — useMailPrint)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
import { useMailPrint } from '@/composables/useMailPrint'
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import { formatAddressList } from '@/shared/utils/mail-address'
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
import type { ConversationRow } from '@/shared/utils/mail-conversation'
import type { ConversationDetailState } from '@/stores/mail.store'
interface Props {
/** 줄을 펼친 단추가 가리키는 id (aria-controls) */
panelId: string
row: ConversationRow
/** 읽기 화면의 보기 방식 — 목록 보기는 [이 메일만 열기]를 바꿔 끼운다 */
layout?: 'split' | 'list'
detail: ConversationDetailState | null
}
interface Emits {
(e: 'retry'): void
}
const props = withDefaults(defineProps<Props>(), {
layout: 'split',
})
const emit = defineEmits<Emits>()
const print = useMailPrint()
const recipients = computed(() => formatAddressList(props.row.message.to))
const subject = computed(() => props.row.message.subject || MAIL_NO_SUBJECT)
const ready = computed(() => (props.detail?.status === 'ready' ? props.detail.detail : null))
const failure = computed(() => (props.detail?.status === 'error' ? props.detail.error : null))
/** 그사이 지워진 메일 — 다시 해도 같고 그 메일로 갈 곳도 없다 */
const missing = computed(() => failure.value?.code === 'RES_001')
const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value) : []))
const bodyTitle = computed(() => MAIL_CONVERSATION_TEXT.bodyTitle(props.row.sender.name, props.row.time))
/** 본문 안에서 누른 키 — Ctrl+P(맥 ⌘P)면 브라우저 인쇄 대신 이 메일의 인쇄 화면 */
function onFrameKeydown(event: KeyboardEvent) {
const current = ready.value
if (!current || !print.isPrintShortcut(event)) return
event.preventDefault()
print.openPrint(current)
}
</script>
<template>
<div
:id="panelId"
class="conversation-panel"
>
<p
v-if="recipients"
class="conversation-panel__meta"
>
<span class="conversation-panel__label">{{ MAIL_CONVERSATION_TEXT.recipients }}</span>
{{ recipients }}
</p>
<p class="conversation-panel__meta conversation-panel__meta--muted">
<span class="conversation-panel__label">{{ MAIL_CONVERSATION_TEXT.subject }}</span>
{{ subject }}
</p>
<template v-if="ready">
<p
v-for="notice in bodyNotices"
:key="notice.kind"
class="conversation-panel__notice"
:data-notice="notice.kind"
>
<AppIcon
name="alert-circle"
:size="15"
class="conversation-panel__notice-icon"
/>
<span>{{ notice.text }}</span>
</p>
<MailBodyFrame
class="conversation-panel__body"
:html-body="ready.htmlBody"
:text-body="ready.textBody"
:images-shown="ready.imagesShown === true"
:title="bodyTitle"
@frame-keydown="onFrameKeydown"
/>
</template>
<p
v-else-if="failure"
class="conversation-panel__error"
data-conversation-detail-error
>
<AppIcon
name="alert-circle"
:size="16"
class="conversation-panel__error-icon"
/>
<span>{{ failure.message }}</span>
<button
v-if="!missing"
type="button"
class="conversation-panel__retry"
@click="emit('retry')"
>
{{ MAIL_CONVERSATION_TEXT.retry }}
</button>
</p>
<div
v-else
class="conversation-panel__loading"
aria-busy="true"
:aria-label="MAIL_CONVERSATION_TEXT.bodyLoading"
data-conversation-detail-loading
>
<span class="conversation-panel__bar" />
<span class="conversation-panel__bar conversation-panel__bar--short" />
</div>
<div
v-if="!missing"
class="conversation-panel__actions"
>
<RouterLink
v-if="row.replyLocation"
class="conversation-panel__action"
:to="row.replyLocation"
data-conversation-reply
>
{{ MAIL_CONVERSATION_TEXT.reply }}
</RouterLink>
<RouterLink
class="conversation-panel__action"
:to="row.location"
:replace="layout === 'list'"
data-conversation-open
>
{{ MAIL_CONVERSATION_TEXT.openOnly }}
</RouterLink>
</div>
<p
class="visually-hidden"
role="status"
data-conversation-detail-status
>
{{ failure?.message ?? '' }}
</p>
</div>
</template>
<style scoped>
/*
* 줄 머리 아래 — 왼쪽 78px 은 이름 선에 맞춘다(줄 안쪽 14 + 화살표 16 + 사이 10 + 머리글자 28 + 사이 10, 디자인 원본 Thread14)
*/
.conversation-panel {
display: flex;
flex-direction: column;
gap: var(--space-10);
padding: var(--space-2) var(--space-16) var(--space-14)
calc(var(--space-14) + var(--space-16) + var(--space-10) + var(--space-28) + var(--space-10));
}
.conversation-panel__meta {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
overflow-wrap: anywhere;
}
.conversation-panel__meta--muted {
color: var(--color-text-muted);
}
.conversation-panel__label {
margin-right: var(--space-6);
}
/* 본문 안내 — 읽기 화면의 본문 안내 줄과 같은 모양 (노랑 · 위아래 10 · 좌우 12, 아이콘은 첫 줄에 — MessageReaderNotices) */
.conversation-panel__notice {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
font-size: var(--font-size-label);
}
.conversation-panel__notice-icon {
flex: 0 0 auto;
margin-top: var(--space-2);
}
/* 펼친 본문은 이 칸 폭을 넘지 않는다 (본문 틀의 글줄 최대 폭은 그대로) */
.conversation-panel__body {
min-width: 0;
}
.conversation-panel__loading {
display: flex;
flex-direction: column;
gap: var(--space-10);
padding: var(--space-6) 0;
}
.conversation-panel__bar {
width: 80%;
height: var(--space-10);
border-radius: var(--radius-xs);
background: var(--color-border-subtle);
}
.conversation-panel__bar--short {
width: 45%;
}
.conversation-panel__error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-8);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.conversation-panel__error-icon {
color: var(--color-danger);
}
/* [다시 시도] 44 — 글자 단추 */
.conversation-panel__retry {
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.conversation-panel__retry:hover {
background: var(--color-brand-subtle);
}
/* [답장] · [이 메일만 열기] — 글자 시작을 위 글과 맞춘다(단추 안쪽 10 만큼 당긴다) */
.conversation-panel__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
margin-left: calc(var(--space-10) * -1);
}
/* 높이 36 — 디자인 원본 Thread14 의 확정값, 안내 줄 단추의 밀도 예외(--size-notice-action)를 다시 쓴다 */
.conversation-panel__action {
display: inline-flex;
align-items: center;
min-height: var(--size-notice-action);
padding: 0 var(--space-10);
border-radius: var(--radius-md);
color: var(--color-brand);
font-weight: 600;
text-decoration: none;
}
.conversation-panel__action:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,208 @@
<script setup lang="ts">
// "같은 대화" 칸의 줄 하나 (14단계 — 디자인 원본 Thread14) — 줄 48: ▸ · 머리글자 · 이름(내가 보냈으면 "나" + 회색 메일함 이름) · 미리 보기 한 줄 · 시각
// - 지금 보는 메일: 옅은 청록 바탕 · "지금 보고 있는 메일" · 누를 수 없다(aria-current). 화살표 자리만큼 들여 머리글자 선을 맞춘다
// - 다른 메일: 단추(aria-expanded · 펼쳤을 때 aria-controls) — 누르면 그 자리에서 펼치고(ConversationMessagePanel) 다시 누르면 접는다.
// 펼친 안의 [다시 시도]가 사라지면 칸이 초점을 이 단추(data-conversation-toggle)로 돌려준다 (MessageConversation)
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ConversationMessagePanel from '@/components/mail/ConversationMessagePanel.vue'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import type { ConversationRow } from '@/shared/utils/mail-conversation'
import type { ConversationDetailState } from '@/stores/mail.store'
interface Props {
row: ConversationRow
/** 읽기 화면의 보기 방식 — 펼친 줄의 [이 메일만 열기]를 기록에 쌓을지 */
layout?: 'split' | 'list'
expanded?: boolean
/** 펼친 줄의 본문 — 아직 받지 않았으면 null */
detail?: ConversationDetailState | null
}
interface Emits {
(e: 'toggle', id: string): void
(e: 'retry-detail', id: string): void
}
const props = withDefaults(defineProps<Props>(), {
layout: 'split',
expanded: false,
detail: null,
})
const emit = defineEmits<Emits>()
/** 펼친 안의 id — 메일 id 는 서버의 불투명한 글이라 그대로 붙인다 */
const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
</script>
<template>
<li
class="conversation-row"
:data-conversation-row="row.message.id"
>
<div
v-if="row.current"
class="conversation-row__line conversation-row__line--current"
aria-current="true"
>
<span
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">{{ row.sender.name }}</span>
<span class="conversation-row__current">{{ MAIL_CONVERSATION_TEXT.current }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
:datetime="row.timeValue"
>{{ row.time }}</time>
</div>
<template v-else>
<button
type="button"
class="conversation-row__line"
:aria-expanded="expanded"
:aria-controls="expanded ? panelId : undefined"
data-conversation-toggle
@click="emit('toggle', row.message.id)"
>
<AppIcon
:name="expanded ? 'chevron-down' : 'chevron-right'"
:stroke-width="2"
class="conversation-row__chevron"
/>
<span
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">{{ row.sender.name }}</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
>{{ row.sender.mailboxName }}</span>
<span class="conversation-row__preview">{{ row.preview }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
:datetime="row.timeValue"
>{{ row.time }}</time>
</button>
<ConversationMessagePanel
v-if="expanded"
:panel-id="panelId"
:row="row"
:layout="layout"
:detail="detail"
@retry="emit('retry-detail', row.message.id)"
/>
</template>
</li>
</template>
<style scoped>
.conversation-row:not(:first-child) {
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* 줄 48 (디자인 원본 Thread14) — 안쪽 위아래 6 · 좌우 14 */
.conversation-row__line {
display: flex;
align-items: center;
gap: var(--space-10);
width: 100%;
min-height: var(--space-48);
padding: var(--space-6) var(--space-14);
border: 0;
background: var(--color-surface);
text-align: left;
}
button.conversation-row__line:hover {
background: var(--color-surface-panel);
}
/* 상자가 넘치는 것을 자르므로(모서리) 고리를 안쪽에 그린다 */
button.conversation-row__line:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
/* 지금 보는 메일 — 옅은 청록 바탕, 화살표(16) + 사이(10) 만큼 들인다 */
.conversation-row__line--current {
padding-left: calc(var(--space-14) + var(--space-16) + var(--space-10));
background: var(--color-brand-tint);
}
.conversation-row__chevron {
color: var(--color-text-muted);
}
/* 머리글자 28 — 내가 보낸 메일은 회색 (디자인 원본 Thread14) */
.conversation-row__avatar {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-28);
height: var(--space-28);
border-radius: var(--radius-round);
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-size: var(--font-size-small);
font-weight: 600;
}
.conversation-row__avatar--mine {
background: var(--color-surface-muted);
color: var(--color-text-secondary);
}
/* 이름은 미리 보기가 다 줄어든 뒤에야 줄인다 (미리 보기의 처음 폭이 0 이다) */
.conversation-row__who {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.conversation-row__mailbox {
flex: 0 0 auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.conversation-row__preview,
.conversation-row__current {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-label);
}
.conversation-row__preview {
color: var(--color-text-muted);
}
.conversation-row__current {
font-weight: 600;
color: var(--color-brand-strong);
}
.conversation-row__time {
flex: 0 0 auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) */
.conversation-row__time.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,782 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK, type Clock } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_PAGE_SIZE, MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화" · 계약 2절 "대화 — 14단계")
// 시각은 지역 시각으로 만든다 — 시간대(KST · UTC)와 상관없이 같은 글이 된다. 시간은 손으로 돌리는 시계로 넘긴다
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 1 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
const MAILBOXES = [INBOX, SENT, DRAFTS]
/** 2026년 11월 6일(금) 오후 3시 — 브라우저 시간대 */
const NOW = new Date(2026, 10, 6, 15).getTime()
const at = (...parts: [number, number, number, number, number]) => new Date(...parts).toISOString()
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: USER.email }],
subject: '회신: 유지보수 계약 갱신 건',
preview: '네, 확인했습니다. 단가와 기간을 정리해서 보내 드리겠습니다.',
receivedAt: at(2026, 9, 29, 9, 30),
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function detail(base: MessageSummary, partial: Partial<MessageDetail> = {}): MessageDetail {
return {
...base,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: `${base.id} 본문`,
hasBlockedImages: false,
attachments: [],
...partial,
}
}
/** 내가 보낸 처음 메일 · 이도윤의 회신(안 읽음) · 지금 보는 메일 */
const ORIGINAL = summary('m1', {
mailboxIds: ['sent'],
from: { name: '김아라', email: USER.email },
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
subject: '유지보수 계약 갱신 건',
preview: '다음 달에 끝나는 유지보수 계약을 1년 더 이어 가려고 합니다.',
receivedAt: at(2026, 9, 27, 14, 10),
})
const REPLY = summary('m2', { isRead: false })
const CURRENT = summary('m3', {
subject: 'Re: 유지보수 계약 갱신 건',
receivedAt: at(2026, 10, 6, 10, 42),
hasAttachment: true,
})
const THREAD = [ORIGINAL, REPLY, CURRENT]
function page(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length): MockResult {
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
}
function deferred() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
interface Server {
conversation?: (request: MockRequest) => MockResult | Promise<MockResult>
detail?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('MessageConversation — 읽기 화면의 같은 대화', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(NOW)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function serve(server: Server = {}) {
requests = installMockApi((request) => {
if (request.url.endsWith('/conversation')) return server.conversation?.(request) ?? page(THREAD)
if (request.method === 'PATCH') return ok({ ...REPLY, isRead: true })
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url.startsWith('/messages/')) {
if (server.detail) return server.detail(request)
const id = request.url.split('/').pop() ?? ''
return ok(detail(THREAD.find((item) => item.id === id) ?? summary(id)))
}
return ok(null)
})
}
async function mountReader(
options: {
path?: string
source?: MailListSource
current?: MessageSummary
layout?: 'split' | 'list'
/** 손으로 돌리는 시계 대신 줄 시계 (시계가 뒤로 가는 경우) */
clock?: Clock
} = {},
) {
const store = useMailStore()
const current = options.current ?? CURRENT
store.mailboxes = MAILBOXES
store.listSource = options.source ?? { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [REPLY, CURRENT]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = detail(current, {
attachments: [{ id: 'a1', name: '유지보수_계약서_수정본.docx', type: 'application/msword', size: 52_000 }],
})
store.selectedId = current.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(options.path ?? `/mail/inbox/${current.id}`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: options.layout ?? 'split' },
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: options.clock ?? clock } },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router, store }
}
const conversationRequests = () => requests.filter((request) => request.url.endsWith('/conversation'))
const section = (wrapper: VueWrapper) => wrapper.get('[data-conversation]')
const rowOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-row="${id}"]`)
describe('칸 모양 (디자인 원본 Thread14)', () => {
it('본문 · 첨부 아래 "같은 대화 3통" — 테두리 상자 안의 줄, 오래된 차례', async () => {
serve()
const { wrapper } = await mountReader()
expect(conversationRequests().map((request) => [request.url, request.params])).toEqual([
['/messages/m3/conversation', { limit: MAIL_CONVERSATION_PAGE_SIZE }],
])
// 본문 · 첨부 다음이 칸 — 화면 낭독용 상태 영역은 늘 그 뒤에 있다(보이지 않는다)
const content = wrapper.get('.reader__content')
const order = [...content.element.children].map((child) => child.className.split(' ')[0])
expect(order.slice(-4)).toEqual(['mail-body-frame', 'attachments', 'conversation', 'visually-hidden'])
expect(content.element.lastElementChild?.getAttribute('role')).toBe('status')
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
expect(section(wrapper).get('h3').attributes('tabindex')).toBe('-1')
expect(section(wrapper).attributes('aria-labelledby')).toBe(section(wrapper).get('h3').attributes('id'))
expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual([
'm1',
'm2',
'm3',
])
})
it('내가 보낸 메일은 "나" + 회색 "보낸편지함"(머리글자도 회색) · 다른 줄은 이름 · 미리 보기 한 줄 · 시각', async () => {
serve()
const { wrapper } = await mountReader()
const mine = rowOf(wrapper, 'm1')
expect(mine.get('.conversation-row__who').text()).toBe('나')
expect(mine.get('.conversation-row__mailbox').text()).toBe('보낸편지함')
expect(mine.get('.conversation-row__avatar').text()).toBe('김')
expect(mine.get('.conversation-row__avatar').classes()).toContain('conversation-row__avatar--mine')
expect(mine.get('.conversation-row__preview').text()).toBe(ORIGINAL.preview)
expect(mine.get('time').text()).toBe('10월 27일 (화) 오후 2:10')
const other = rowOf(wrapper, 'm2')
expect(other.get('.conversation-row__who').text()).toBe('이도윤')
expect(other.find('.conversation-row__mailbox').exists()).toBe(false)
expect(other.get('.conversation-row__avatar').classes()).not.toContain('conversation-row__avatar--mine')
expect(other.get('time').text()).toBe('10월 29일 (목) 오전 9:30')
// 강조 · 뱃지 없이 글로만
expect(section(wrapper).findAll('mark')).toHaveLength(0)
})
it('지금 보는 메일의 줄은 "지금 보고 있는 메일"(aria-current)이고 누를 수 없다 — 다른 줄은 접힌 단추(aria-expanded="false")', async () => {
serve()
const { wrapper } = await mountReader()
const current = rowOf(wrapper, 'm3')
expect(current.find('button').exists()).toBe(false)
expect(current.get('[aria-current="true"]').text()).toContain('지금 보고 있는 메일')
expect(current.get('time').text()).toBe('오늘 오전 10:42')
for (const id of ['m1', 'm2']) {
const button = rowOf(wrapper, id).get('button')
expect(button.attributes('aria-expanded')).toBe('false')
expect(button.attributes('aria-controls')).toBeUndefined()
}
})
it('한 통뿐이면 칸이 없다', async () => {
serve({ conversation: () => page([CURRENT]) })
const { wrapper } = await mountReader()
expect(conversationRequests()).toHaveLength(1)
expect(wrapper.find('[data-conversation]').exists()).toBe(false)
expect(wrapper.find('[data-conversation-failed]').exists()).toBe(false)
})
it('통 수를 모르면(남은 쪽이 있고 total null) 수 없이 "같은 대화" — 상자 아래 [더 보기]', async () => {
const full = Array.from({ length: MAIL_CONVERSATION_PAGE_SIZE - 1 }, (_, index) => summary(`p${index}`))
serve({
conversation: ({ params }) => (params.cursor ? page([REPLY], null, null) : page([...full, CURRENT], 'c2', null)),
})
const { wrapper } = await mountReader()
expect(section(wrapper).get('h3').text()).toBe('같은 대화')
const more = wrapper.get('[data-conversation-more]')
expect(more.text()).toBe('더 보기')
await more.trigger('click')
expect(more.attributes('aria-busy')).toBe('true')
expect(more.text()).toBe('불러오는 중…')
await flushPromises()
expect(conversationRequests().at(-1)?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE, cursor: 'c2' })
expect(wrapper.find('[data-conversation-more]').exists()).toBe(false)
expect(section(wrapper).get('h3').text()).toBe(`같은 대화 ${MAIL_CONVERSATION_PAGE_SIZE + 1}통`)
})
it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중"', async () => {
const sendAt = new Date(2026, 10, 7, 9, 0).toISOString()
serve({
conversation: () =>
page([
...THREAD,
summary('s1', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'scheduled', sendAt } }),
summary('s2', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'undo', sendAt } }),
]),
})
const { wrapper } = await mountReader()
expect(rowOf(wrapper, 's1').get('time').text()).toBe('예약 · 11월 7일 오전 9:00')
expect(rowOf(wrapper, 's1').get('time').classes()).toContain('is-pending')
expect(rowOf(wrapper, 's2').get('time').text()).toBe('보내는 중')
expect(rowOf(wrapper, 's2').get('time').attributes('datetime')).toBe(sendAt)
})
})
describe('펼치기 · 접기', () => {
it('다른 줄을 누르면 그 자리에서 펼친다 — 받는 사람 · 제목 · 본문(강조 쿼리 없이) · [답장] · [이 메일만 열기], 안 읽은 메일이면 읽음으로', async () => {
serve()
const { wrapper, store } = await mountReader()
const button = rowOf(wrapper, 'm2').get('button')
await button.trigger('click')
expect(button.attributes('aria-expanded')).toBe('true')
const panelId = button.attributes('aria-controls')
expect(panelId).toBe('conversation-panel-m2')
expect(rowOf(wrapper, 'm2').find('[data-conversation-detail-loading]').attributes('aria-busy')).toBe('true')
await flushPromises()
const panel = wrapper.get(`#${panelId}`)
expect(panel.findAll('.conversation-panel__meta').map((line) => line.text())).toEqual([
'받는 사람 김아라 <ara.kim@arasystem.test>',
'제목 회신: 유지보수 계약 갱신 건',
])
const frame = panel.get('iframe')
expect(frame.attributes('srcdoc')).toContain('m2 본문')
expect(frame.attributes('title')).toBe('메일 본문: 이도윤, 10월 29일 (목) 오전 9:30')
expect(panel.get('[data-conversation-reply]').attributes('href')).toBe('/mail/compose?replyTo=m2')
expect(panel.get('[data-conversation-reply]').text()).toBe('답장')
expect(panel.get('[data-conversation-open]').attributes('href')).toBe('/mail/inbox/m2')
expect(panel.get('[data-conversation-open]').text()).toBe('이 메일만 열기')
expect(requests.find((request) => request.method === 'GET' && request.url === '/messages/m2')?.params).toEqual({})
expect(requests.filter((request) => request.method === 'PATCH').map((request) => [request.url, request.data])).toEqual([
['/messages/m2', { isRead: true }],
])
// 목록의 그 줄도 읽음으로 — 읽기 화면에서 연 것과 같은 길
expect(store.messages.find((message) => message.id === 'm2')?.isRead).toBe(true)
await button.trigger('click')
expect(button.attributes('aria-expanded')).toBe('false')
expect(wrapper.find(`#${panelId}`).exists()).toBe(false)
})
it('내가 보낸 메일의 [이 메일만 열기]는 그 메일이 든 보낸편지함으로 — 누르면 기록에 쌓아 옮긴다', async () => {
serve()
const { wrapper, router } = await mountReader()
const push = vi.spyOn(router, 'push')
await rowOf(wrapper, 'm1').get('button').trigger('click')
await flushPromises()
const open = rowOf(wrapper, 'm1').get('[data-conversation-open]')
expect(open.attributes('href')).toBe('/mail/sent/m1')
await open.trigger('click')
await flushPromises()
expect(push).toHaveBeenCalled()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/m1')
})
it('임시 저장본 · 예약한 메일에는 [답장]이 없다 — 임시 저장본의 [이 메일만 열기]는 쓰기 화면에서 이어 쓴다', async () => {
const sendAt = new Date(2026, 10, 7, 9, 0).toISOString()
const draft = summary('d1', { mailboxIds: ['drafts'], from: USER })
const scheduled = summary('s1', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'scheduled', sendAt } })
serve({ conversation: () => page([...THREAD, draft, scheduled]) })
const { wrapper } = await mountReader()
expect(rowOf(wrapper, 'd1').get('.conversation-row__mailbox').text()).toBe('임시보관함')
await rowOf(wrapper, 'd1').get('button').trigger('click')
await rowOf(wrapper, 's1').get('button').trigger('click')
await flushPromises()
expect(rowOf(wrapper, 'd1').find('[data-conversation-reply]').exists()).toBe(false)
expect(rowOf(wrapper, 'd1').get('[data-conversation-open]').attributes('href')).toBe('/mail/compose?draft=d1')
expect(rowOf(wrapper, 's1').find('[data-conversation-reply]').exists()).toBe(false)
expect(rowOf(wrapper, 's1').get('[data-conversation-open]').attributes('href')).toBe('/mail/sent/s1')
})
it('본문을 못 받으면 줄 안에 사전 문구 + [다시 시도] — 알리지 않는다', async () => {
let failing = true
serve({ detail: (request) => (failing ? fail(503, 'MAIL_001') : ok(detail(summary(request.url.split('/').pop() ?? '')))) })
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('button').trigger('click')
await flushPromises()
const error = rowOf(wrapper, 'm2').get('[data-conversation-detail-error]')
expect(error.text()).toContain('메일 서비스에 연결할 수 없습니다.')
expect(useToastStore().toasts).toEqual([])
// 읽지 못했으니 읽음으로 바꾸지 않는다
expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
failing = false
await error.get('button').trigger('click')
await flushPromises()
expect(rowOf(wrapper, 'm2').find('iframe').exists()).toBe(true)
})
})
describe('받지 못했을 때 — 읽기 화면은 그대로', () => {
it('칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] · 문의 코드 — 알리지 않는다, 누르면 다시 받는다', async () => {
let failing = true
serve({ conversation: () => (failing ? fail(503, 'MAIL_001') : page(THREAD)) })
const { wrapper } = await mountReader()
expect(wrapper.find('#reader-subject').exists()).toBe(true)
const notice = wrapper.get('[data-conversation-failed]')
expect(notice.text()).toContain('같은 대화를 불러오지 못했습니다.')
expect(notice.text()).toContain('문의 코드 3f1c0a2e')
expect(useToastStore().toasts).toEqual([])
// 화면 낭독용 상태 영역은 늘 있고 글만 바뀐다 — 안내 줄 자체에는 role 이 없다(두 번 읽지 않게)
expect(notice.attributes('role')).toBeUndefined()
const status = wrapper.get('[data-conversation-status]')
expect(status.attributes('role')).toBe('status')
expect(status.text()).toBe('같은 대화를 불러오지 못했습니다.')
failing = false
await notice.get('[data-conversation-retry]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-conversation-failed]').exists()).toBe(false)
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
expect(wrapper.get('[data-conversation-status]').text()).toBe('')
})
it('없는 메일(404 RES_001 — 이 경로가 없는 옛 서버 포함) · 모양이 틀린 응답이면 칸 없이', async () => {
serve({ conversation: () => fail(404, 'RES_001') })
const missing = await mountReader()
expect(missing.wrapper.find('[data-conversation]').exists()).toBe(false)
expect(missing.wrapper.find('[data-conversation-failed]').exists()).toBe(false)
missing.wrapper.unmount()
setActivePinia((pinia = createPinia()))
useAuthStore().markAuthenticated(USER)
serve({ conversation: () => ok({ usedBytes: 0 }) })
const odd = await mountReader()
expect(odd.wrapper.find('[data-conversation]').exists()).toBe(false)
expect(odd.wrapper.find('[data-conversation-failed]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('보호 화면을 쓸 수 없는 세션(로그인 전 · 제한된 세션)에는 묻지 않는다', async () => {
useAuthStore().clearAuth()
serve()
await mountReader()
expect(conversationRequests()).toEqual([])
})
it('제한된 세션(새 비밀번호를 정하기 전 — 13단계)이면 묻지 않고, 제한이 풀리면 그때 받는다', async () => {
const auth = useAuthStore()
auth.markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
serve()
const { wrapper } = await mountReader()
expect(auth.canUseApp).toBe(false)
expect(conversationRequests()).toEqual([])
auth.markAuthenticated(USER)
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
})
})
describe('다시 받기 — 바뀜 소식은 모아서 · 돌아오면 바로', () => {
it(`다시 받아 달라는 소식이 오면 다시 받는다 — 마지막으로 받기가 끝난 지 ${MAIL_CONVERSATION_REFRESH_GAP_MS}ms 안이면 그때까지 모아 한 번`, async () => {
let thread = THREAD
serve({ conversation: () => page(thread) })
const { wrapper, store } = await mountReader()
expect(conversationRequests()).toHaveLength(1)
clock.advance(1000)
store.requestConversationRefresh()
await flushPromises()
store.requestConversationRefresh()
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
thread = [...THREAD, summary('m4', { mailboxIds: ['sent'], from: USER, receivedAt: at(2026, 10, 6, 14, 0) })]
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS - 1000)
await flushPromises()
expect(conversationRequests()).toHaveLength(2)
expect(conversationRequests()[1]?.config.silent).toBe(true)
expect(section(wrapper).get('h3').text()).toBe('같은 대화 4통')
// 간격이 지난 뒤의 소식은 바로
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
store.requestConversationRefresh()
await flushPromises()
expect(conversationRequests()).toHaveLength(3)
})
it('받아 둔 대화로 돌아오면(쓰기 화면에서 답장을 보내고) 보이는 채로 바로 다시 받는다', async () => {
serve()
const first = await mountReader()
first.wrapper.unmount()
expect(conversationRequests()).toHaveLength(1)
const back = mount(MessageReader, {
attachTo: document.body,
global: { plugins: [pinia, first.router], provide: { [CLOCK as symbol]: clock } },
})
mounted.push(back)
expect(back.find('[data-conversation]').exists()).toBe(true)
await flushPromises()
expect(conversationRequests()).toHaveLength(2)
expect(conversationRequests()[1]?.config.silent).toBe(true)
})
it('메일을 바꾸면 그 메일로 새로 받고, 앞 메일의 늦은 응답은 버린다', async () => {
const late = deferred()
serve({
conversation: ({ url }) =>
url === '/messages/m3/conversation' ? late.promise : page([summary('x1'), summary('x2', { subject: '다른 대화' })]),
})
const { wrapper, store } = await mountReader()
store.selected = detail(summary('x2', { subject: '다른 대화' }))
store.selectedId = 'x2'
await flushPromises()
late.resolve(page(THREAD))
await flushPromises()
expect(conversationRequests().map((request) => request.url)).toEqual([
'/messages/m3/conversation',
'/messages/x2/conversation',
])
expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual(['x1', 'x2'])
})
})
describe('검색 결과에서 연 메일 (/mail/search/:id)', () => {
it('칸이 보이지만 강조가 없다 — 대화 · 펼친 본문 모두 검색 쿼리 없이 받는다', async () => {
serve()
const { wrapper, store } = await mountReader({
path: `/mail/search/m3?q=${encodeURIComponent('계약')}&in=all`,
source: { kind: 'search', q: '계약', scope: 'all' },
})
store.selectedHighlight = { q: '계약' }
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
expect(conversationRequests()[0]?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE })
await rowOf(wrapper, 'm2').get('button').trigger('click')
await flushPromises()
expect(requests.find((request) => request.method === 'GET' && request.url === '/messages/m2')?.params).toEqual({})
expect(section(wrapper).findAll('mark')).toHaveLength(0)
// 받아 둔 결과에 있는 메일은 검색 결과 안에서 연다
expect(rowOf(wrapper, 'm2').get('[data-conversation-open]').attributes('href')).toBe(
`/mail/search/m2?q=${encodeURIComponent('계약')}&in=all`,
)
})
})
describe('초점 — 누른 단추 · 초점이 있던 줄이 다시 그리며 사라지면', () => {
const focusOn = (element: Element) => (element as HTMLElement).focus()
it('칸의 [다시 시도] — 또 못 받으면 새로 그린 [다시 시도]로, 받으면(받는 동안은 기다렸다가) 칸 제목(h3)으로', async () => {
let failing = true
const retried = deferred()
serve({ conversation: () => (failing ? fail(503, 'MAIL_001') : retried.promise) })
const { wrapper } = await mountReader()
focusOn(wrapper.get('[data-conversation-retry]').element)
await wrapper.get('[data-conversation-retry]').trigger('click')
await flushPromises()
expect(document.activeElement).toBe(wrapper.get('[data-conversation-retry]').element)
failing = false
focusOn(wrapper.get('[data-conversation-retry]').element)
await wrapper.get('[data-conversation-retry]').trigger('click')
await flushPromises()
expect(document.activeElement).toBe(document.body)
retried.resolve(page(THREAD))
await flushPromises()
expect(document.activeElement).toBe(section(wrapper).get('h3').element)
})
it('[더 보기] — 받는 동안은 그 단추에 남고(aria-busy), 마지막 쪽을 받아 단추가 사라지면 칸 제목으로', async () => {
const full = Array.from({ length: MAIL_CONVERSATION_PAGE_SIZE - 1 }, (_, index) => summary(`p${index}`))
serve({
conversation: ({ params }) => (params.cursor ? page([REPLY], null, null) : page([...full, CURRENT], 'c2', null)),
})
const { wrapper } = await mountReader()
const more = wrapper.get('[data-conversation-more]')
focusOn(more.element)
await more.trigger('click')
expect(document.activeElement).toBe(more.element)
await flushPromises()
expect(wrapper.find('[data-conversation-more]').exists()).toBe(false)
expect(document.activeElement).toBe(section(wrapper).get('h3').element)
})
it('펼친 줄의 [다시 시도] — 본문을 받으면 그 줄의 펼침 단추로', async () => {
let failing = true
serve({
detail: (request) => (failing ? fail(503, 'MAIL_001') : ok(detail(summary(request.url.split('/').pop() ?? '')))),
})
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
failing = false
const retry = rowOf(wrapper, 'm2').get('[data-conversation-detail-error] button')
focusOn(retry.element)
await retry.trigger('click')
await flushPromises()
expect(rowOf(wrapper, 'm2').find('iframe').exists()).toBe(true)
expect(document.activeElement).toBe(rowOf(wrapper, 'm2').get('[data-conversation-toggle]').element)
})
it('다시 받아 초점이 있던 줄이 빠지면 칸 제목으로, 칸이 없어지면(한 통만 남음) 읽기 화면의 본문으로', async () => {
let thread = THREAD
serve({ conversation: () => page(thread) })
const { wrapper, store } = await mountReader()
async function refreshWith(next: MessageSummary[]) {
thread = next
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
store.requestConversationRefresh()
await flushPromises()
}
focusOn(rowOf(wrapper, 'm2').get('[data-conversation-toggle]').element)
await refreshWith([ORIGINAL, CURRENT])
expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false)
expect(document.activeElement).toBe(section(wrapper).get('h3').element)
focusOn(rowOf(wrapper, 'm1').get('[data-conversation-toggle]').element)
await refreshWith([CURRENT])
expect(wrapper.find('[data-conversation]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('.reader__content iframe').element)
})
it('초점이 칸 밖에 있으면 다시 받아 줄이 빠져도 건드리지 않는다', async () => {
let thread = THREAD
serve({ conversation: () => page(thread) })
const { wrapper, store } = await mountReader()
const star = wrapper.get('.reader__star')
focusOn(star.element)
thread = [ORIGINAL, CURRENT]
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
store.requestConversationRefresh()
await flushPromises()
expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false)
expect(document.activeElement).toBe(star.element)
})
})
describe('다시 받기의 때 — 받는 중에 온 소식 · 시계가 뒤로 감', () => {
it('처음 받는 중에 온 소식은 버리지 않는다 — 받기가 끝난 때부터 간격을 세어 한 번 더 받는다', async () => {
const first = deferred()
let calls = 0
serve({
conversation: () => {
calls += 1
return calls === 1 ? first.promise : page(THREAD)
},
})
const { store } = await mountReader()
store.requestConversationRefresh()
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
// 받는 동안 흐른 시간은 세지 않는다
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
first.resolve(page(THREAD))
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS - 1)
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
clock.advance(1)
await flushPromises()
expect(conversationRequests()).toHaveLength(2)
})
it('이 PC 시계가 뒤로 가도 간격보다 오래 기다리지 않는다', async () => {
let offset = 0
const skewed: Clock = {
now: () => clock.now() + offset,
setTimeout: (callback, ms) => clock.setTimeout(callback, ms),
clearTimeout: (timer) => clock.clearTimeout(timer),
}
serve()
const { store } = await mountReader({ clock: skewed })
offset = -60 * 60 * 1000
store.requestConversationRefresh()
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
await flushPromises()
expect(conversationRequests()).toHaveLength(2)
})
})
describe('펼친 줄의 본문 안내 · 그사이 지워진 메일', () => {
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다', async () => {
serve({ detail: () => ok(detail(REPLY, { isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '글자 본문' })) })
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
const row = rowOf(wrapper, 'm2')
expect(row.findAll('[data-notice]').map((notice) => [notice.attributes('data-notice'), notice.text()])).toEqual([
['htmlOmitted', MAIL_BODY_NOTICES.htmlOmitted],
['truncated', MAIL_BODY_NOTICES.truncated],
])
expect(row.find('iframe').exists()).toBe(true)
})
it('본문이 404 RES_001(그사이 지워짐)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 알리고, 대화를 다시 받아 그 줄을 뺀다', async () => {
const refreshed = deferred()
let calls = 0
serve({
conversation: () => {
calls += 1
return calls === 1 ? page(THREAD) : refreshed.promise
},
detail: () => fail(404, 'RES_001'),
})
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
const row = rowOf(wrapper, 'm2')
expect(row.get('[data-conversation-detail-error]').text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(row.find('[data-conversation-detail-error] button').exists()).toBe(false)
expect(row.find('[data-conversation-reply]').exists()).toBe(false)
expect(row.find('[data-conversation-open]').exists()).toBe(false)
expect(row.get('[data-conversation-detail-status]').text()).toBe(ERROR_CODE_MESSAGES.RES_001)
expect(conversationRequests()).toHaveLength(2)
refreshed.resolve(page([ORIGINAL, CURRENT]))
await flushPromises()
expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false)
expect(section(wrapper).get('h3').text()).toBe('같은 대화 2통')
expect(useToastStore().toasts).toEqual([])
})
})
describe('펼친 본문 안의 Ctrl+P — 그 펼친 메일의 인쇄 화면', () => {
it('펼친 본문 안에서 누른 Ctrl+P 는 읽기 화면의 메일이 아니라 펼친 메일의 인쇄 화면을 연다 — 다른 글쇠와 함께면 브라우저에 맡긴다', async () => {
const opened: unknown[][] = []
vi.spyOn(window, 'open').mockImplementation((...args: unknown[]) => {
opened.push(args)
return null
})
serve()
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
const frame = rowOf(wrapper, 'm2').get('iframe').element as HTMLIFrameElement
frame.dispatchEvent(new Event('load'))
const press = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, bubbles: true, cancelable: true })
frame.contentDocument?.dispatchEvent(press)
expect(press.defaultPrevented).toBe(true)
expect(opened).toEqual([['/mail/print/m2', '_blank', 'noopener']])
const withShift = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true })
frame.contentDocument?.dispatchEvent(withShift)
expect(withShift.defaultPrevented).toBe(false)
expect(opened).toHaveLength(1)
})
})
describe('[이 메일만 열기] — 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다', () => {
it.each([
['split', 'push'],
['list', 'replace'],
] as const)('%s 보기 → router.%s', async (layout, method) => {
serve()
const { wrapper, router } = await mountReader({ layout })
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
await rowOf(wrapper, 'm2').get('[data-conversation-open]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
expect(method === 'push' ? push : replace).toHaveBeenCalled()
expect(method === 'push' ? replace : push).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,307 @@
<script setup lang="ts">
// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화", 계약 2절 "대화 — 14단계")
// - 본문 · 첨부 아래 "같은 대화 N통" — N 은 total, 모르면 다 받았을 때의 받은 수, 그것도 모르면 수 없이 "같은 대화". 한 통뿐이면 칸이 없다
// - 테두리 상자 안의 줄(ConversationMessageRow — 48), 오래된 차례. 지금 보는 메일의 줄은 "지금 보고 있는 메일"이고 다른 줄은 그 자리에서 펼친다
// - 받을 쪽이 남았으면 상자 아래 [더 보기](44) — 받는 동안 "불러오는 중…"(aria-busy, 초점을 잃지 않게 꺼짐 없이)
// - 받지 못하면 칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] — 읽기 화면은 그대로다. 없는 메일(RES_001)이면 칸 없이.
// 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다(나중에 생긴 영역은 읽어 주지 않는다)
// - 검색 결과에서 연 메일에도 보이지만 칸 안의 줄 · 본문에는 검색 강조를 넣지 않는다
// - 초점: 누른 단추 · 초점이 있던 줄이 다시 그리며 사라지면(받은 [다시 시도] · 마지막 쪽을 받은 [더 보기] · 펼친 줄의 [다시 시도] ·
// 다시 받아 빠진 줄) 그 줄이 남았으면 그 줄의 펼침 단추로, 아니면 칸 제목(h3 — tabindex -1)으로. 받는 중이면 다 받을 때까지 기다리고,
// 칸이 없어졌으면 읽기 화면에 맡긴다(settled — 본문으로). 초점이 칸 밖에 있었으면 건드리지 않는다
// 받는 때(메일이 바뀜 · 쓰기 화면에서 돌아옴 · 메일함들의 통 수가 바뀜을 모아서)는 useConversationSync 가 정한다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ConversationMessageRow from '@/components/mail/ConversationMessageRow.vue'
import { useClock } from '@/composables/useClock'
import { useConversationSync } from '@/composables/useConversationSync'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import { formatCount, formatTraceCode } from '@/shared/utils/format'
import { conversationRowsOf } from '@/shared/utils/mail-conversation'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
interface Props {
/** 읽기 화면에 연 메일 */
messageId: string
/** split: 분할 보기 · list: 목록 보기 — [이 메일만 열기]를 기록에 쌓을지(목록 보기는 바꿔 끼운다) */
layout?: 'split' | 'list'
}
interface Emits {
/** 초점이 있던 칸이 없어졌다 — 읽기 화면이 초점을 옮긴다 (본문으로) */
(e: 'settled'): void
}
const props = withDefaults(defineProps<Props>(), {
layout: 'split',
})
const emit = defineEmits<Emits>()
const mailStore = useMailStore()
const authStore = useAuthStore()
const clock = useClock()
useConversationSync({ messageId: () => props.messageId, clock })
const sectionRef = ref<HTMLElement | null>(null)
const titleRef = ref<HTMLHeadingElement | null>(null)
const failureRef = ref<HTMLElement | null>(null)
/** 화면 낭독용 상태 — 받지 못했을 때의 안내 (영역은 늘 있다) */
const announcement = ref<string>('')
/** 칸이 이 메일의 대화를 가리킬 때만 그린다 — 메일을 바꾼 직후 앞 메일의 대화가 잠깐 보이지 않게 */
const forThisMessage = computed(() => mailStore.conversationMessageId === props.messageId)
const visible = computed(() => forThisMessage.value && mailStore.conversationVisible)
const failed = computed(() => forThisMessage.value && mailStore.conversationFailed)
const showMore = computed(() => mailStore.hasMoreConversation || mailStore.conversationLoadingMore)
const title = computed(() => {
const count = mailStore.conversationCount
return count === null ? MAIL_CONVERSATION_TEXT.title : MAIL_CONVERSATION_TEXT.titleCount(formatCount(count))
})
const traceCode = computed(() => formatTraceCode(mailStore.conversationError?.traceId))
const listedIds = computed<ReadonlySet<string>>(() => new Set(mailStore.messages.map((message) => message.id)))
const rows = computed(() =>
conversationRowsOf(mailStore.conversationItems, {
currentId: props.messageId,
me: authStore.user,
mailboxes: mailStore.mailboxes,
source: mailStore.listSource,
listedIds: listedIds.value,
now: new Date(clock.now()),
}),
)
function loadMore() {
if (!mailStore.conversationLoadingMore) void mailStore.loadMoreConversation()
}
// ── 초점 지키기 ─────────────────────────────────────────
/** 다시 그릴 때 바뀌는 것 — 칸 · 안내 · [더 보기] · 줄 · 펼친 줄과 그 본문의 상태 */
const renderKey = computed(() => {
const details = mailStore.conversationDetails
const expanded = [...mailStore.conversationExpandedIds].map((id) => `${id}=${details.get(id)?.status ?? ''}`)
return [
visible.value,
failed.value,
showMore.value,
mailStore.conversationStatus,
mailStore.conversationItems.map((item) => item.id).join(','),
expanded.join(','),
].join('|')
})
/** 칸 안에서 잃은 초점 — 초점이 있던 줄(없으면 null). 받는 중이면 다 받을 때까지 남는다 */
let lostFocus: { rowId: string | null } | null = null
function isInside(element: Element | null): boolean {
if (!element) return false
return Boolean(sectionRef.value?.contains(element) || failureRef.value?.contains(element))
}
function rowToggle(rowId: string): HTMLElement | null {
const rowElement = [...(sectionRef.value?.querySelectorAll<HTMLElement>('[data-conversation-row]') ?? [])].find(
(element) => element.dataset.conversationRow === rowId,
)
return rowElement?.querySelector<HTMLElement>('[data-conversation-toggle]') ?? null
}
/** 그린 뒤 — 초점을 잃었으면(body) 갈 곳으로 옮긴다 */
function settleFocus() {
if (!lostFocus) return
const active = document.activeElement
if (active !== null && active !== document.body) {
// 초점이 남아 있거나 사용자가 다른 곳으로 옮겼다
lostFocus = null
return
}
const target =
(lostFocus.rowId ? rowToggle(lostFocus.rowId) : null) ??
failureRef.value?.querySelector<HTMLElement>('[data-conversation-retry]') ??
titleRef.value
if (target) {
lostFocus = null
target.focus()
return
}
// 받는 중 — 칸이 다시 생길 때까지 기다린다
if (mailStore.conversationStatus === 'loading' && forThisMessage.value) return
lostFocus = null
emit('settled')
}
// 그리기 전에 초점이 칸 안에 있었는지 적어 둔다 (pre — 아직 옛 화면이다)
watch(
renderKey,
() => {
const active = document.activeElement
if (isInside(active)) {
const row = active?.closest<HTMLElement>('[data-conversation-row]')
lostFocus = { rowId: row?.dataset.conversationRow ?? null }
}
void nextTick(settleFocus)
},
{ flush: 'pre' },
)
// 받지 못했을 때만 읽어 준다 — 비웠다가 넣어 다시 못 받아도 다시 읽게
watch(failed, async (isFailed) => {
announcement.value = ''
if (!isFailed) return
await nextTick()
announcement.value = MAIL_CONVERSATION_TEXT.loadFailed
})
</script>
<template>
<section
v-if="visible"
ref="sectionRef"
class="conversation"
aria-labelledby="reader-conversation-title"
data-conversation
>
<h3
id="reader-conversation-title"
ref="titleRef"
class="conversation__title"
tabindex="-1"
>
{{ title }}
</h3>
<ol class="conversation__list">
<ConversationMessageRow
v-for="row in rows"
:key="row.message.id"
:row="row"
:layout="layout"
:expanded="mailStore.conversationExpandedIds.has(row.message.id)"
:detail="mailStore.conversationDetails.get(row.message.id) ?? null"
@toggle="mailStore.toggleConversationMessage"
@retry-detail="mailStore.retryConversationDetail"
/>
</ol>
<div
v-if="showMore"
class="conversation__more"
>
<button
type="button"
class="conversation__text-button"
:aria-busy="mailStore.conversationLoadingMore"
data-conversation-more
@click="loadMore"
>
{{ mailStore.conversationLoadingMore ? MAIL_CONVERSATION_TEXT.moreBusy : MAIL_CONVERSATION_TEXT.more }}
</button>
</div>
</section>
<div
v-else-if="failed"
ref="failureRef"
class="conversation conversation--failed"
data-conversation-failed
>
<p class="conversation__failure">
<AppIcon
name="alert-circle"
class="conversation__failure-icon"
/>
<span>{{ MAIL_CONVERSATION_TEXT.loadFailed }}</span>
<button
type="button"
class="conversation__text-button"
data-conversation-retry
@click="mailStore.retryConversation()"
>
{{ MAIL_CONVERSATION_TEXT.retry }}
</button>
<span
v-if="traceCode"
class="conversation__trace"
>{{ MAIL_CONVERSATION_TEXT.traceCode(traceCode) }}</span>
</p>
</div>
<p
class="visually-hidden"
role="status"
data-conversation-status
>
{{ announcement }}
</p>
</template>
<style scoped>
/* 첨부 칸과 같은 칸 머리 — 위 선 · 12.5px 회색 제목 (디자인 원본 Thread14) */
.conversation {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.conversation__title {
margin: 0;
outline-offset: var(--focus-ring-offset);
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 테두리 상자 — 줄 사이는 옅은 선 (ConversationMessageRow) */
.conversation__list {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.conversation__more {
display: flex;
}
/* [더 보기] · [다시 시도] 44 — 글자 단추 */
.conversation__text-button {
min-height: var(--size-touch);
padding: 0 var(--space-12);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.conversation__text-button:hover {
background: var(--color-brand-subtle);
}
.conversation__text-button[aria-busy='true'] {
color: var(--color-text-muted);
}
.conversation__failure {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-8);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.conversation__failure-icon {
color: var(--color-danger);
}
.conversation__trace {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
</style>
@@ -636,8 +636,9 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await items[1]?.trigger('click')
await flushPromises()
// 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다
// 읽기 화면은 열릴 때 같은 대화를 받는다(14단계). 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
'GET /messages/m1/conversation',
'GET /mail-filters',
'GET /contacts',
'GET /contacts',
+12 -3
View File
@@ -15,6 +15,7 @@
// 대용량 첨부(11단계 — largeFiles)는 첨부 칸에 첨부처럼 보이고, 본문의 덩이는 접는다(본문 틀 안 — 스크립트 없이 열고 닫는다).
// 검색 결과에서 연 메일(14단계)은 제목 · 본문 · 첨부 이름의 맞은 말이 노랑이다 — 서버의 searchHighlight(제목 · 첨부 이름 · 글 본문의 위치)와
// 서식 본문 안의 <mark>. 강조가 든 응답은 여기서 보이기만 한다 — [답장] · [전달] · 인쇄는 그 메일을 검색 쿼리 없이 따로 받는다 (계약 2절 "검색 — 14단계")
// 본문 · 첨부 아래에 "같은 대화 N통"(14단계 — MessageConversation, 한 통뿐이면 없다). 칸 안에는 검색 강조가 없다
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -25,13 +26,14 @@ import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
import MessageConversation from '@/components/mail/MessageConversation.vue'
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
import MessageReaderNotices from '@/components/mail/MessageReaderNotices.vue'
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMailPrint } from '@/composables/useMailPrint'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { MAIL_NO_SENDER, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
import { listedAttachments } from '@/shared/utils/mail-attachment'
@@ -67,7 +69,7 @@ const searchMailboxName = computed(() => {
if (!mailStore.search || !message.value) return null
return mailboxOfMessage(message.value, mailStore.mailboxes, mailStore.currentMailboxId)?.name ?? null
})
const fromName = computed(() => displayName(message.value?.from) || '(보낸 사람 없음)')
const fromName = computed(() => displayName(message.value?.from) || MAIL_NO_SENDER)
const fromEmail = computed(() => message.value?.from?.email ?? '')
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
const subject = computed(() => message.value?.subject || '(제목 없음)')
@@ -98,7 +100,7 @@ function onPrintKey(event: KeyboardEvent) {
print.openPrint(current)
}
/** 안내 줄의 단추가 줄과 함께 사라졌다 — 초점을 잃었으면 본문으로 */
/** 안내 줄의 단추가 줄과 함께 사라졌다 · 초점이 있던 같은 대화 칸이 없어졌다(14단계) — 초점을 잃었으면 본문으로 */
function onNoticeSettled() {
const active = document.activeElement
if (active !== null && active !== document.body) return
@@ -286,6 +288,13 @@ onBeforeUnmount(() => {
:large-files="largeFiles"
:name-highlights="message.searchHighlight?.attachments ?? null"
/>
<!-- 같은 대화 (14단계) — 한 통뿐이거나 아직 받지 못했으면 아무것도 그리지 않는다. 초점이 있던 칸이 없어지면 본문으로 -->
<MessageConversation
:message-id="message.id"
:layout="layout"
@settled="onNoticeSettled"
/>
</div>
</article>
@@ -0,0 +1,112 @@
import { onScopeDispose, watch } from 'vue'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
// 읽기 화면의 "같은 대화" 칸을 언제 받을지 (14단계 — 계약 2절 "대화 — 14단계"). 칸(MessageConversation)이 시작하고, 칸이 사라지면 멈춘다
// - 메일이 바뀌면 그 메일의 대화를 받는다(스토어의 loadConversation — 같은 대화의 다른 메일이면 보이던 줄을 둔 채)
// - 그 메일의 대화를 이미 받아 두었으면(쓰기 화면에서 답장을 보내고 돌아옴 · 보기 방식을 바꿈) 보이는 채로 바로 다시 받는다
// - 다시 받아 달라는 소식(conversationRefreshSeq — 메일함들의 통 수가 바뀌었을 때만 오른다, mail.store.ts)이 오면 다시 받는다 —
// 마지막으로 받기가 끝난 지 MAIL_CONVERSATION_REFRESH_GAP_MS 가 지나지 않았으면 그때까지 모았다가 한 번에 (잦지 않게).
// 끝난 때는 스토어가 실제로 물었을 때만 센다(conversationBusy 가 꺼질 때). 묻는 중에 온 소식은 끝난 뒤 한 번 더(그때부터 간격)
// - 기다림은 간격보다 길지 않다 — 이 PC 시계가 뒤로 가도 오래 멈추지 않게
// - 보호 화면을 쓸 수 있는 세션(canUseApp)일 때만 묻는다 — 로그인 전 · 제한된 세션(13단계)에는 묻지 않는다
// 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts)
export interface ConversationSyncOptions {
/** 읽기 화면에 연 메일 */
messageId: () => string
clock?: Clock
}
export function useConversationSync(options: ConversationSyncOptions): void {
const mailStore = useMailStore()
const authStore = useAuthStore()
const clock = options.clock ?? systemClock
/** 마지막으로 받기가 끝난 때 — 이 칸이 뜬 뒤 실제로 물은 것만 */
let lastFinishedAt: number | null = null
let timer: ClockTimer | null = null
/** 묻는 중에 온 소식 — 끝난 뒤 한 번 더 */
let pending = false
let stopped = false
function cancelTimer() {
if (timer !== null) clock.clearTimeout(timer)
timer = null
}
/** 지금 다시 받는다 — 묻는 중이면 끝난 뒤로 미룬다 */
function refreshNow() {
if (stopped || !authStore.canUseApp) return
if (mailStore.conversationBusy) {
pending = true
return
}
void mailStore.refreshConversation()
}
/**
* 다시 받아 달라 — 묻는 중이면 끝난 뒤로 미루고, 간격이 지났으면 바로, 아니면 간격이 끝날 때 한 번 (그사이 온 요청은 함께).
* 간격이 끝나면 다시 재지 않고 받는다 — 시계가 뒤로 가도 간격보다 오래 기다리지 않게
*/
function requestRefresh() {
if (stopped || !authStore.canUseApp) return
if (mailStore.conversationBusy) {
pending = true
return
}
if (timer !== null) return
const wait =
lastFinishedAt === null
? 0
: Math.min(MAIL_CONVERSATION_REFRESH_GAP_MS, lastFinishedAt + MAIL_CONVERSATION_REFRESH_GAP_MS - clock.now())
if (wait <= 0) {
refreshNow()
return
}
timer = clock.setTimeout(() => {
timer = null
refreshNow()
}, wait)
}
// 받기가 끝날 때마다(받기 · 다시 받기 · [더 보기] · 붙잡힌 메일 다시 보기) 그때부터 간격을 센다 — 묻는 중에 온 소식은 그때 한 번 더
watch(
() => mailStore.conversationBusy,
(busy, wasBusy) => {
if (stopped || busy || !wasBusy) return
lastFinishedAt = clock.now()
if (!pending) return
pending = false
requestRefresh()
},
)
watch(
[() => options.messageId(), () => authStore.canUseApp],
([messageId, usable]) => {
cancelTimer()
pending = false
if (!usable) return
if (mailStore.conversationMessageId === messageId && mailStore.conversationStatus === 'ready') {
requestRefresh()
return
}
void mailStore.loadConversation(messageId)
},
{ immediate: true },
)
watch(
() => mailStore.conversationRefreshSeq,
() => requestRefresh(),
)
onScopeDispose(() => {
stopped = true
cancelTimer()
})
}
+22
View File
@@ -10,8 +10,10 @@ import {
MAIL_SEND_TIMEOUT_MS,
MAIL_UPLOAD_TIMEOUT_MS,
} from '@/shared/constants/mail'
import { MAIL_CONVERSATION_TIMEOUT_MS } from '@/shared/constants/mail-conversation'
import type {
Attachment,
ConversationPage,
Draft,
DraftPayload,
DraftSaved,
@@ -49,6 +51,7 @@ import type {
UpdateMessagePayload,
} from '@/shared/types/mail'
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
import { conversationPageOf } from '@/shared/utils/mail-conversation'
import { pendingSendOf } from '@/shared/utils/pending-send'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
@@ -273,6 +276,24 @@ export function useMail() {
})
}
/**
* 그 메일의 같은 대화 — 오래된 차례, 한 통뿐이면 그 메일 하나 (14단계 — 계약 2절 "대화 — 14단계").
* hasNext 인데 항목이 적거나 0개인 쪽이 올 수 있다(엔진 방식) — 이어 묻는 것은 부르는 쪽(스토어의 conversation.ts)이다.
* 커서는 그 메일에서만 통한다. 응답은 모양을 맞춰 돌려준다 — 틀리면 빈 대화(칸이 없다)
*/
async function getConversation(
id: string,
query: CursorPaginationQuery,
options?: MailRequestOptions,
): Promise<ConversationPage> {
const page = await api.get<unknown, unknown>(`/messages/${segment(id)}/conversation`, {
timeout: MAIL_CONVERSATION_TIMEOUT_MS,
...options,
params: query,
})
return conversationPageOf(page)
}
function updateMessage(
id: string,
changes: UpdateMessagePayload,
@@ -478,6 +499,7 @@ export function useMail() {
listMessages,
getSearchIndex,
getMessage,
getConversation,
updateMessage,
updateMessages,
deleteMessages,
@@ -160,4 +160,30 @@ describe('usePendingSendRefresh — 보낼 시각이 지나면 다시 본다', (
expect(listRequests()).toEqual([])
expect(clock.pendingCount()).toBe(0)
})
it('읽기 화면 "같은 대화" 칸에만 있는 붙잡힌 메일도 지켜본다 — 보낼 시각 + 2초에 그 대화를 알리지 않고 다시 받아 "보내는 중"이 내려간다 (14단계)', async () => {
let thread = [summary('m3', null), summary('u1', { kind: 'undo', sendAt: at(10_000) })]
requests = installMockApi((request) => {
if (request.url === '/scheduled-messages') return page([])
if (request.url.endsWith('/conversation')) {
return ok({ items: thread, pageInfo: { hasNext: false, nextCursor: null, total: thread.length }, basis: 'index' })
}
return ok({})
})
const store = useMailStore()
await store.loadConversation('m3')
scope.run(() => usePendingSendRefresh({ clock }))
const conversations = () => requests.filter((request) => request.url.endsWith('/conversation'))
expect(conversations()).toHaveLength(1)
thread = [summary('m3', null), summary('u1', null)]
await advance(11_999)
expect(conversations()).toHaveLength(1)
await advance(1)
expect(conversations()).toHaveLength(2)
expect(conversations()[1]?.config.silent).toBe(true)
expect(store.conversationItems.find((item) => item.id === 'u1')?.pendingSend).toBeNull()
expect(clock.pendingCount()).toBe(0)
})
})
@@ -17,8 +17,9 @@ import { useMailStore } from '@/stores/mail.store'
// 엔진이 붙잡아 둔 발송을 풀 때(보냄) 화면을 다시 맞춘다 (계약 2절 "보내기 취소 · 예약 발송" — 9단계, 메일 화면 MailLayout 이 시작)
// 엔진이 풀어도 새 메일 확인(GET /mail-state) · 실시간 알림의 state 는 바뀌지 않는다 — 그대로 두면 보낸편지함의 "보내는 중" · "예약 · 시각",
// 패널의 「예약됨」 수, 읽기 화면의 청록 줄이 계속 남고, 그 메일의 [고치기] · [예약 취소]가 매번 "이미 보낸 메일"(MAIL_014)이 된다 (2026-10-29 리뷰).
// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄)의 pendingSend.sendAt — 같은 메일은 하나로 본다
// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수, 알리지 않는다)
// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄) · 읽기 화면 "같은 대화" 칸의 줄(14단계)의
// pendingSend.sendAt — 같은 메일은 하나로 본다
// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수 · 붙잡힌 메일이 든 같은 대화, 알리지 않는다)
// - 다시 본 뒤에도 시각이 지난 채 남아 있으면(서버가 늦음 · 이 PC 시계가 빠름) 5초마다 세 번까지 더 보고, 그래도 남으면 그 발송은 그만 본다
// (다음 새 메일 확인 · 화면 이동이 맞춘다 — 같은 요청을 끝없이 보내지 않게). 처음부터 시각이 지나 보이는 것(시계가 어긋남)도 5초 뒤부터 같다
// 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts)
@@ -40,7 +41,12 @@ export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) {
const givenUp = new Set<string>()
const watches = computed<PendingWatch[]>(() =>
pendingWatchesOf([...mailStore.messages, mailStore.selected, mailStore.nextScheduled]),
pendingWatchesOf([
...mailStore.messages,
mailStore.selected,
mailStore.nextScheduled,
...mailStore.conversationItems,
]),
)
/** 지금 지켜볼 것 — 그만 보기로 한 것은 빼고, 사라진 것은 잊는다 */
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import { CLOCK } from '@/composables/useClock'
import { routes } from '@/router/routes'
import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
// 메일 화면의 바뀜 소식(1분 확인 · 실시간 — MailLayout 의 onChange) → 메일함 개수 맞추기 → 같은 대화 칸 다시 받기 (14단계).
// 읽음 · 중요 표시만 바뀐 소식(메일함들의 통 수가 그대로)으로는 대화를 다시 받지 않는다 — 대화 경로의 요청 수 한도를 아끼려고(리뷰 중간 2).
// 실제 메일 화면(MailLayout → MailboxPage → 읽기 화면 → 같은 대화 칸)을 띄우고 손으로 돌리는 시계로 1분 확인을 넘긴다
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
function mailbox(id: string, role: Mailbox['role'], name: string, totalCount: number, unreadCount: number): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount }
}
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: USER.email }],
subject: '회신: 유지보수 계약 갱신 건',
preview: `미리 보기 ${id}`,
receivedAt: new Date(2026, 10, 5, 9, 30).toISOString(),
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function detail(base: MessageSummary): MessageDetail {
return {
...base,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: `${base.id} 본문`,
hasBlockedImages: false,
attachments: [],
}
}
const page = (items: unknown[], total = items.length) => ok({ items, pageInfo: { hasNext: false, nextCursor: null, total } })
describe('MailLayout — 바뀜 소식으로 같은 대화 칸을 다시 받는다 (메일함들의 통 수가 바뀔 때만)', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
let state: string
let inboxTotal: number
let inboxUnread: number
let thread: MessageSummary[]
const mounted: VueWrapper[] = []
beforeEach(() => {
window.localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(new Date(2026, 10, 6, 15).getTime())
state = 's1'
inboxTotal = 2
inboxUnread = 1
thread = [summary('m1', { mailboxIds: ['sent'], from: USER }), summary('m3')]
requests = installMockApi(({ url, method }) => {
if (url === '/mail-state') return ok({ state })
if (url === '/mailboxes') {
return page([mailbox('inbox', 'inbox', '받은편지함', inboxTotal, inboxUnread), mailbox('sent', 'sent', '보낸편지함', 1, 0)])
}
if (url === '/mail-quota') {
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
}
if (url === '/mail-signature') {
return ok({ bodyHtml: null, body: null, isForNew: true, isForReply: true, images: [], updatedAt: null })
}
if (url === '/mail-vacation') {
return ok({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', isActive: true })
}
if (url === '/mail-blocks') return ok({ senders: [], isActive: true })
if (url === '/mail-settings') return ok({ undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false })
if (url === '/scheduled-messages') return page([])
if (url === '/messages' && method === 'GET') return page([summary('m3'), summary('m2', { threadId: 't2' })])
if (url.endsWith('/conversation')) return ok({ items: thread, pageInfo: { hasNext: false, nextCursor: null, total: thread.length }, basis: 'index' })
if (url === '/messages/m3') return ok(detail(summary('m3')))
return ok(null)
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
document.title = ''
})
async function settle() {
for (let round = 0; round < 4; round += 1) await flushPromises()
}
async function mountMail() {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/mail/inbox/m3')
await router.isReady()
const wrapper = mount(
{ render: () => h(RouterView) },
{ attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } },
)
mounted.push(wrapper)
await settle()
return wrapper
}
const conversationRequests = () => requests.filter((request) => request.url.endsWith('/conversation'))
const mailboxRequests = () => requests.filter((request) => request.url === '/mailboxes')
/** 1분 확인을 넘긴다 — 바뀐 state 면 메일 화면이 메일함 개수 · 보이는 목록을 맞춘다(onChange) */
async function nextPoll(nextState: string) {
state = nextState
clock.advance(MAIL_STATE_POLL_INTERVAL_MS)
await settle()
}
it('읽음 표시만 바뀐 소식(통 수 그대로)으로는 다시 받지 않고, 새 메일로 통 수가 바뀌면 다시 받는다', async () => {
const wrapper = await mountMail()
expect(wrapper.find('[data-conversation]').exists()).toBe(true)
expect(conversationRequests()).toHaveLength(1)
// 다른 곳에서 메일 한 통을 읽었다 — 안 읽은 수만 바뀐다
const mailboxLoads = mailboxRequests().length
inboxUnread = 0
await nextPoll('s2')
expect(mailboxRequests().length).toBeGreaterThan(mailboxLoads)
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
await settle()
expect(conversationRequests()).toHaveLength(1)
// 같은 대화에 새 메일이 왔다 — 받은편지함의 통 수가 바뀐다
inboxTotal = 3
thread = [...thread, summary('m4', { receivedAt: new Date(2026, 10, 6, 15, 2).toISOString() })]
await nextPoll('s3')
expect(conversationRequests()).toHaveLength(2)
expect(conversationRequests()[1]?.config.silent).toBe(true)
expect(wrapper.get('[data-conversation] h3').text()).toBe('같은 대화 3통')
})
it(`받기가 끝난 지 ${MAIL_CONVERSATION_REFRESH_GAP_MS}ms 안에 온 소식은 그때까지 모았다가 한 번 받는다`, async () => {
await mountMail()
expect(conversationRequests()).toHaveLength(1)
// 처음 받기가 끝난 바로 뒤 — 실시간 소식처럼 곧바로 메일함 개수를 맞춘 것으로 본다
inboxTotal = 3
useMailStore().syncCounts()
await settle()
expect(conversationRequests()).toHaveLength(1)
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
await settle()
expect(conversationRequests()).toHaveLength(2)
})
})
+1
View File
@@ -57,6 +57,7 @@ useMailStatePolling({
void mailStore.loadScheduledCount()
// 열린 메일의 대용량 첨부가 보안 검사 중이면 상태를 다시 받는다 (11단계 — 끝나면 받을 수 있다)
void mailStore.refreshOpenedLargeFiles()
// 읽기 화면의 "같은 대화" 칸은 위 syncCounts 로 받은 메일함들의 통 수가 바뀌었을 때만 다시 받는다 (14단계 — mail.store.ts 의 mailboxTotalsKey)
},
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
})
@@ -0,0 +1,54 @@
// 같은 대화 — 읽기 화면의 "같은 대화" 칸 (14단계 — 계약 2절 "대화 — 14단계" · 디자인 README 「14단계 — 검색 · 대화 묶기」 › "읽기 화면 — 같은 대화 (Thread14)")
// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다 — 이 파일이 원본이다.
/** 한 번에 받는 수 — 계약의 처음 값이자 최대(100). 대화는 대개 한 쪽이다 */
export const MAIL_CONVERSATION_PAGE_SIZE = 100
/**
* 쪽이 모자라면 이어 묻는 횟수 — 칸을 열 때 · [더 보기] 한 번에 요청을 이만큼까지 보낸다(검색의 이어 찾기 MAIL_SEARCH_FILL_MAX_REQUESTS 와 같은 결).
* 엔진 방식(그 직원의 색인을 채우는 동안)은 한 요청이 스레드를 1,000통까지만 훑어 hasNext 인데 항목이 적거나 0개인 쪽이 온다 —
* 새로 한 쪽(100통)이 차거나 더 없으면 멈추고, 그래도 남으면 [더 보기]로 사용자가 잇는다
*/
export const MAIL_CONVERSATION_FILL_MAX_REQUESTS = 5
/**
* 요청 하나의 시간 한도 — 서버는 엔진 방식의 한 요청을 15초 안에 끝내고(넘으면 503 MAIL_001) 그 전에 색인을 1.5초 맞춘다.
* 기본 10초(useApi)로는 서버가 답하기 전에 끊기므로 넉넉히 둔다
*/
export const MAIL_CONVERSATION_TIMEOUT_MS = 30_000
/**
* 바뀜 소식으로 다시 받는 사이의 최소 간격 — 마지막으로 받기가 **끝난** 뒤 이만큼 안에 온 소식은 모았다가 간격이 끝날 때 한 번 받는다.
* 소식은 메일함들의 통 수가 바뀌었을 때만 온다(새 메일 · 지움 · 옮김 — 읽음 · 중요 표시만 바뀐 것은 아니다, mail.store.ts).
* 대화 경로도 요청 수 한도(경로마다 · IP 마다 1분 100회 — 사무실은 한 IP)를 함께 쓴다
*/
export const MAIL_CONVERSATION_REFRESH_GAP_MS = 10_000
/**
* 접힌 줄의 받아 둔 본문 수 — 최근에 본 것부터 이만큼만 둔다(다시 펴면 다시 받는다). 펼친 줄의 본문은 세지 않고 늘 둔다
*/
export const MAIL_CONVERSATION_DETAIL_CACHE_MAX = 10
/** 읽기 화면의 "같은 대화" 칸 (디자인 원본 Thread14) */
export const MAIL_CONVERSATION_TEXT = {
/** 칸 제목 — 통 수를 모르면(total null — 쪽을 넘긴 뒤 · 엔진 방식의 큰 대화) 수 없이 */
title: '같은 대화',
titleCount: (count: string) => `같은 대화 ${count}통`,
/** 내가 보낸 메일의 이름 자리 (3차 "대화로 묶어 보기"의 참여자 줄도 같은 말) */
me: '나',
current: '지금 보고 있는 메일',
reply: '답장',
openOnly: '이 메일만 열기',
/** 펼친 줄의 머리 — 읽기 화면 머리와 같은 이름 ("받는 사람 김아라 <…>" · "제목 …") */
recipients: '받는 사람',
subject: '제목',
/** 펼친 줄의 본문을 받는 동안 (화면 낭독) */
bodyLoading: '메일 불러오는 중',
/** 펼친 줄의 본문 틀 이름 (화면 낭독) — 같은 대화는 제목이 같아 보낸 사람 · 시각으로 가른다 */
bodyTitle: (who: string, time: string) => `메일 본문: ${who}, ${time}`,
more: '더 보기',
moreBusy: '불러오는 중…',
loadFailed: '같은 대화를 불러오지 못했습니다.',
retry: '다시 시도',
traceCode: (code: string) => `문의 코드 ${code}`,
} as const
+2
View File
@@ -240,6 +240,8 @@ export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다.
/** 임시보관함 목록 — 받는 사람 · 제목이 없을 때 (디자인 원본 ComposeAssist "임시보관함 목록") */
export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
export const MAIL_NO_SUBJECT = '(제목 없음)'
/** 보낸 사람이 없는 메일 — 목록 · 읽기 화면 · 같은 대화 칸의 이름 자리 */
export const MAIL_NO_SENDER = '(보낸 사람 없음)'
// ── 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일") ─────────────
/** 화면이 보이는 동안 이 간격으로 바뀐 것이 있는지 묻는다 — 실시간 연결이 살아 있으면 묻지 않는다 (5단계, mail-notify.ts) */
+15 -1
View File
@@ -3,7 +3,7 @@
import type { ContactEmailLabel } from '@/shared/types/contacts'
import type { DraftLargeFile, MessageLargeFile } from '@/shared/types/large-file'
import type { CursorPaginationQuery } from '@/shared/types/pagination'
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
export interface Address {
name: string | null
@@ -202,6 +202,20 @@ export interface SearchIndexState {
total: number | null
}
// ── 대화 (계약 2절 "대화 — 14단계") ─────────────────────────
/** 대화의 기준 — index 우리 색인(답장 관계 + 말머리를 뗀 제목이 같음) · engine 엔진의 스레드(그 직원의 색인을 채우는 동안) */
export type ConversationBasis = 'index' | 'engine'
/**
* GET /messages/{id}/conversation 응답 — 그 메일의 같은 대화, 오래된 차례(한 통뿐이면 그 메일 하나).
* hasNext 인데 항목이 limit 보다 적거나 0개일 수 있다(engine — 한 요청이 스레드를 1,000통까지만 훑는다).
* total 은 index 면 첫 쪽에서만, engine 이면 쪽마다(1,000통을 넘으면 null). 모양이 틀린 응답은 빈 대화 · basis null 로 맞춘다 (useMail().getConversation)
*/
export interface ConversationPage extends PaginatedResult<MessageSummary> {
basis: ConversationBasis | null
}
/** PATCH /messages/{id} 본문 — 하나 이상 지정한다 */
export interface UpdateMessagePayload {
isRead?: boolean
+22
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
formatBlockedDay,
formatConversationTime,
formatDraftSavedTime,
formatPrintDateTime,
formatScheduleShortTime,
@@ -215,3 +216,24 @@ describe('formatRelativeDayTime (11단계 — 설정 › 대용량 첨부의 날
expect(formatRelativeDayTime('나중에', now, SEOUL)).toBe('')
})
})
describe('formatConversationTime (14단계 — 같은 대화 칸의 시각, 디자인 원본 Thread14)', () => {
/** 2026-11-05(목) 오후 3시 (서울) */
const now = new Date('2026-11-05T06:00:00Z')
it('오늘 · 어제는 그 말과 시각 — "오늘 오전 10:42"', () => {
expect(formatConversationTime('2026-11-05T01:42:00Z', now, SEOUL)).toBe('오늘 오전 10:42')
expect(formatConversationTime('2026-11-04T04:05:00Z', now, SEOUL)).toBe('어제 오후 1:05')
})
it('그 밖의 올해는 요일까지 "10월 29일 (목) 오전 9:30", 다른 해는 연도까지 (읽기 화면 머리와 같다)', () => {
expect(formatConversationTime('2026-10-29T00:30:00Z', now, SEOUL)).toBe('10월 29일 (목) 오전 9:30')
expect(formatConversationTime('2025-10-29T00:30:00Z', now, SEOUL)).toBe('2025. 10. 29. (수) 오전 9:30')
})
it('날은 서울 날짜로 가른다 — UTC 로는 어제여도 서울로 오늘이면 "오늘"', () => {
// 2026-11-04T23:30Z = 서울 11월 5일 오전 8:30
expect(formatConversationTime('2026-11-04T23:30:00Z', now, SEOUL)).toBe('오늘 오전 8:30')
expect(formatConversationTime('나중에', now, SEOUL)).toBe('')
})
})
+17
View File
@@ -274,6 +274,23 @@ export function formatScheduleTime(value: string | number, timeZone?: string): s
}).format(date)
}
/**
* 같은 대화 칸의 시각 (14단계 디자인 원본 Thread14: "오늘 오전 10:42" · "10월 29일 (목) 오전 9:30") — 줄마다 날과 시각을 함께 보인다
* - 오늘 · 어제: "오늘 오전 10:42" · "어제 오후 1:05"
* - 올해: "10월 29일 (목) 오전 9:30"
* - 그 이전: "2025. 10. 29. (수) 오전 9:30" (읽기 화면 머리와 같다)
*/
export function formatConversationTime(value: string, now: Date = new Date(), timeZone?: string): string {
const date = parseDate(value)
if (!date) return ''
const target = calendarDayOf(date, timeZone)
const today = calendarDayOf(now, timeZone)
const time = new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date)
if (isSameDay(target, today)) return `오늘 ${time}`
if (isSameDay(target, previousDay(today))) return `어제 ${time}`
return target.year === today.year ? formatScheduleTime(value, timeZone) : formatMailDateTime(value, timeZone)
}
/** 보낸편지함 날짜 칸의 예약 시각 — 요일 없이 (디자인 원본 MailSendLater: "예약 · 10월 30일 오전 9:00") */
export function formatScheduleShortTime(value: string | number, timeZone?: string): string {
const date = dateOf(value)
@@ -0,0 +1,274 @@
import { describe, expect, it } from 'vitest'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import {
appendConversationPage,
conversationCountOf,
conversationMessageLocation,
conversationPageOf,
conversationReplyLocation,
conversationRowsOf,
conversationSenderOf,
hasConversation,
nextConversationCursor,
participantName,
type ConversationLinkContext,
} from './mail-conversation'
// 같은 대화 (14단계 — 계약 2절 "대화 — 14단계" · 디자인 원본 Thread14) — 응답 맞추기 · 통 수 · 이름 자리 · 누르면 갈 곳
const SEOUL = 'Asia/Seoul'
const ME = { email: 'ara.kim@arasystem.test', name: '김아라' }
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 }
}
const INBOX = mailbox('inbox', 'inbox', '받은편지함')
const SENT = mailbox('sent', 'sent', '보낸편지함')
const DRAFTS = mailbox('drafts', 'drafts', '임시보관함')
const PROJECT = mailbox('project', null, '프로젝트')
const MAILBOXES = [INBOX, SENT, DRAFTS, PROJECT]
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: ME.email }],
subject: '회신: 유지보수 계약 갱신 건',
preview: '네, 확인했습니다.',
receivedAt: '2026-10-29T00:30:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
...partial,
}
}
const EMPTY_LINKS: ConversationLinkContext = {
source: { kind: 'mailbox', mailboxId: 'inbox' },
listedIds: new Set(),
mailboxes: MAILBOXES,
}
describe('conversationPageOf — 응답 모양 맞추기', () => {
it('계약의 모양이면 그대로 — 기준(basis)도 싣는다', () => {
const page = conversationPageOf({
items: [summary('m1'), summary('m2')],
pageInfo: { hasNext: true, nextCursor: 'c2', total: 4 },
basis: 'index',
})
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2'])
expect(page.pageInfo).toEqual({ hasNext: true, nextCursor: 'c2', total: 4 })
expect(page.basis).toBe('index')
})
it('모양이 틀리면(항목 · 쪽 정보가 없음 · 응답을 바꾼 게이트웨이) 빈 대화 — 칸을 보이지 않는다', () => {
for (const value of [null, 'html', { items: 'x', pageInfo: {} }, { items: [] }, { usedBytes: 0 }]) {
const page = conversationPageOf(value)
expect(page.items).toEqual([])
expect(page.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: 0 })
expect(page.basis).toBeNull()
expect(hasConversation(page.items, page.pageInfo)).toBe(false)
}
})
it('커서 없는 hasNext 는 다음 쪽이 없는 것, 음수 · 소수 total 은 모르는 것, 모르는 basis 는 null, 줄이 읽는 칸이 없는 항목은 뺀다', () => {
const page = conversationPageOf({
items: [summary('m1'), { id: 'broken' }, { ...summary('m3'), preview: undefined }, summary('m4')],
pageInfo: { hasNext: true, nextCursor: '', total: 2.5 },
basis: 'thread',
})
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm4'])
expect(page.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: null })
expect(page.basis).toBeNull()
expect(conversationPageOf({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: -1 } }).pageInfo.total).toBeNull()
})
})
describe('appendConversationPage · 통 수 · 칸 보이기', () => {
it('다음 쪽을 붙인다 — 이미 있는 메일은 건너뛰고, total 이 없는 다음 쪽(색인 방식)은 첫 쪽의 수를 남긴다', () => {
const first = { hasNext: true, nextCursor: 'c2', total: 3 }
const merged = appendConversationPage([summary('m1'), summary('m2')], first, {
items: [summary('m2'), summary('m3')],
pageInfo: { hasNext: false, nextCursor: null, total: null },
basis: 'index',
})
expect(merged.items.map((item) => item.id)).toEqual(['m1', 'm2', 'm3'])
expect(merged.added).toBe(1)
expect(merged.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: 3 })
})
it('N 은 total — 모르면 다 받았을 때의 받은 수, 그것도 모르면(남은 쪽이 있다) null', () => {
const items = [summary('m1'), summary('m2')]
expect(conversationCountOf(items, { hasNext: true, nextCursor: 'c2', total: 8 })).toBe(8)
expect(conversationCountOf(items, { hasNext: false, nextCursor: null, total: null })).toBe(2)
expect(conversationCountOf(items, { hasNext: true, nextCursor: 'c2', total: null })).toBeNull()
expect(conversationCountOf(items, null)).toBeNull()
})
it('한 통뿐이면 칸이 없다 — 수를 모르면 두 통 넘게 받았거나 받을 쪽이 남았을 때 보인다', () => {
expect(hasConversation([summary('m1')], { hasNext: false, nextCursor: null, total: 1 })).toBe(false)
expect(hasConversation([summary('m1')], { hasNext: false, nextCursor: null, total: null })).toBe(false)
expect(hasConversation([summary('m1'), summary('m2')], { hasNext: false, nextCursor: null, total: 2 })).toBe(true)
// 엔진 방식의 큰 대화 — 첫 쪽에는 지금 메일뿐이지만 다른 메일이 있다는 수가 왔다
expect(hasConversation([summary('m1')], { hasNext: true, nextCursor: 'c2', total: 5 })).toBe(true)
expect(hasConversation([summary('m1')], { hasNext: true, nextCursor: 'c2', total: null })).toBe(true)
expect(nextConversationCursor({ hasNext: true, nextCursor: null, total: null })).toBeNull()
})
})
describe('이름 자리 — 내가 보냈으면 "나" + 회색 메일함 이름 (디자인 원본 Thread14)', () => {
const context = { me: ME, mailboxes: MAILBOXES }
it('보낸편지함의 메일은 "나" · 보낸편지함 — 머리글자는 내 이름의 첫 글자, 회색', () => {
const sent = summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME.email } })
expect(conversationSenderOf(sent, context)).toEqual({ name: '나', initial: '김', mine: true, mailboxName: '보낸편지함' })
})
it('임시 저장본은 "나" · 임시보관함, 보낸 사람이 나면(대소문자 무시) 받은편지함에 들어도 "나" — 메일함 이름 없이', () => {
const draft = summary('m1', { mailboxIds: ['drafts'], from: null })
expect(conversationSenderOf(draft, context)).toEqual({ name: '나', initial: '김', mine: true, mailboxName: '임시보관함' })
const toSelf = summary('m2', { from: { name: null, email: 'Ara.Kim@AraSystem.test' } })
expect(conversationSenderOf(toSelf, context)).toMatchObject({ name: '나', mine: true, mailboxName: null })
})
it('받은 메일은 보낸 사람 이름(없으면 주소, 보낸 사람도 없으면 "(보낸 사람 없음)") — 내 메일함이 아닌 곳의 이름은 붙이지 않는다', () => {
expect(conversationSenderOf(summary('m1', { mailboxIds: ['project'] }), context)).toEqual({
name: '이도윤',
initial: '이',
mine: false,
mailboxName: null,
})
expect(conversationSenderOf(summary('m2', { from: { name: null, email: 'seojun.park@arasystem.test' } }), context).name).toBe(
'seojun.park@arasystem.test',
)
expect(conversationSenderOf(summary('m3', { from: null }), context)).toMatchObject({ name: '(보낸 사람 없음)', mine: false })
})
it('메일함을 아직 모르면 보낸 사람 주소로만 가른다', () => {
const sent = summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME.email } })
expect(conversationSenderOf(sent, { me: ME, mailboxes: [] })).toMatchObject({ name: '나', mailboxName: null })
expect(conversationSenderOf(sent, { me: null, mailboxes: [] })).toMatchObject({ name: '김아라', mine: false })
})
it('participantName — 나면 "나", 아니면 이름 (3차 묶은 목록의 참여자 줄도 쓴다)', () => {
expect(participantName({ name: '김아라', email: ME.email }, ME.email)).toBe('나')
expect(participantName({ name: '이도윤', email: 'doyun.lee@arasystem.test' }, ME.email)).toBe('이도윤')
expect(participantName({ name: null, email: 'x@example.com' }, null)).toBe('x@example.com')
})
})
describe('누르면 갈 곳 — [이 메일만 열기] · [답장]', () => {
it('지금 메일함에 든 메일은 그 메일함 안, 다른 메일함의 메일은 그 메일이 든 메일함 (보낸편지함의 내 메일)', () => {
expect(conversationMessageLocation(summary('m2'), EMPTY_LINKS)).toEqual({
name: 'mail-message',
params: { mailboxId: 'inbox', messageId: 'm2' },
})
expect(conversationMessageLocation(summary('m1', { mailboxIds: ['sent'] }), EMPTY_LINKS)).toEqual({
name: 'mail-message',
params: { mailboxId: 'sent', messageId: 'm1' },
})
})
it('임시 저장본은 쓰기 화면에서 이어 쓴다 (목록에서 누를 때와 같다)', () => {
expect(conversationMessageLocation(summary('d1', { mailboxIds: ['drafts'] }), EMPTY_LINKS)).toEqual({
name: 'mail-compose',
query: { draft: 'd1' },
})
})
it('검색 결과 · 예약됨은 받아 둔 줄에 있는 메일만 그 목록 안에서 연다 — 아니면 그 메일의 메일함', () => {
const search: ConversationLinkContext = {
source: { kind: 'search', q: '계약', scope: 'all' },
listedIds: new Set(['m2']),
mailboxes: MAILBOXES,
}
expect(conversationMessageLocation(summary('m2'), search)).toEqual({
name: 'mail-search-message',
params: { messageId: 'm2' },
query: { q: '계약', in: 'all' },
})
expect(conversationMessageLocation(summary('m3', { mailboxIds: ['project'] }), search)).toEqual({
name: 'mail-message',
params: { mailboxId: 'project', messageId: 'm3' },
})
const scheduled: ConversationLinkContext = { source: { kind: 'scheduled' }, listedIds: new Set(['s1']), mailboxes: MAILBOXES }
expect(conversationMessageLocation(summary('s1', { mailboxIds: ['sent'] }), scheduled)).toEqual({
name: 'mail-scheduled-message',
params: { messageId: 's1' },
})
})
it('메일함을 모르면 그 메일의 첫 메일함 id 로', () => {
expect(
conversationMessageLocation(summary('m1', { mailboxIds: ['x1'] }), { ...EMPTY_LINKS, mailboxes: [] }),
).toEqual({ name: 'mail-message', params: { mailboxId: 'x1', messageId: 'm1' } })
})
it('[답장]은 읽기 화면과 같은 쓰기 화면 — 임시 저장본 · 예약한 메일은 없고, 보내기 취소를 기다리는 메일은 있다', () => {
expect(conversationReplyLocation(summary('m1'), MAILBOXES)).toEqual({ name: 'mail-compose', query: { replyTo: 'm1' } })
expect(conversationReplyLocation(summary('d1', { mailboxIds: ['drafts'] }), MAILBOXES)).toBeNull()
const sendAt = '2026-10-30T00:00:00Z'
expect(
conversationReplyLocation(summary('s1', { mailboxIds: ['sent'], pendingSend: { kind: 'scheduled', sendAt } }), MAILBOXES),
).toBeNull()
expect(
conversationReplyLocation(summary('s2', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt } }), MAILBOXES),
).not.toBeNull()
})
})
describe('conversationRowsOf — 칸의 줄', () => {
/** 2026-10-29(목) 오후 3시 (서울) */
const now = new Date('2026-10-29T06:00:00Z')
const context = { ...EMPTY_LINKS, currentId: 'm3', me: ME, now, timeZone: SEOUL }
it('오래된 차례 그대로 — 지금 메일 표시 · 시각("오늘 …" · "10월 27일 (화) …") · 미리 보기가 비면 제목', () => {
const rows = conversationRowsOf(
[
summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME.email }, receivedAt: '2026-10-27T05:10:00Z' }),
summary('m2', { preview: ' ' }),
summary('m3', { receivedAt: '2026-10-29T01:42:00Z' }),
],
context,
)
expect(rows.map((row) => [row.message.id, row.current, row.sender.name, row.time])).toEqual([
['m1', false, '나', '10월 27일 (화) 오후 2:10'],
['m2', false, '이도윤', '오늘 오전 9:30'],
['m3', true, '이도윤', '오늘 오전 10:42'],
])
expect(rows[1]?.preview).toBe('회신: 유지보수 계약 갱신 건')
expect(rows[0]?.preview).toBe('네, 확인했습니다.')
expect(rows[0]?.location).toEqual({ name: 'mail-message', params: { mailboxId: 'sent', messageId: 'm1' } })
})
it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중" — <time> 은 보낼 시각', () => {
const sendAt = '2026-10-30T00:00:00Z'
const rows = conversationRowsOf(
[
summary('s1', { mailboxIds: ['sent'], pendingSend: { kind: 'scheduled', sendAt } }),
summary('s2', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt } }),
summary('m3'),
],
context,
)
expect(rows.map((row) => [row.time, row.pending, row.timeValue])).toEqual([
['예약 · 10월 30일 오전 9:00', true, sendAt],
['보내는 중', true, sendAt],
['오늘 오전 9:30', false, '2026-10-29T00:30:00Z'],
])
expect(rows[0]?.replyLocation).toBeNull()
})
})
@@ -0,0 +1,247 @@
// 같은 대화 (14단계 — 계약 2절 "대화 — 14단계" · 디자인 README "읽기 화면 — 같은 대화 (Thread14)") — 순수 함수만 둔다
// 읽기 화면의 "같은 대화" 칸(MessageConversation)이 쓰고, 3차 "대화로 묶어 보기"도 참여자 이름("나") · 쪽 맞추기를 다시 쓸 수 있게 나눠 둔다.
// - 응답 맞추기: 모양이 틀리면 빈 대화(칸이 없다), 쪽을 붙일 때 이미 있는 메일은 건너뛰고 total 이 없는 다음 쪽은 아는 수를 남긴다
// - 통 수 · 칸 보이기: total → 다 받았으면 받은 수 → 모르면 수 없이. 한 통뿐이면 칸이 없다
// - 이름 자리: 내가 보낸 메일(보낸편지함 · 임시보관함에 들었거나 보낸 사람이 나)은 "나" + 회색 메일함 이름
// - 누르면 갈 곳: 임시 저장본은 쓰기 화면(목록과 같다), 지금 목록에 있는 메일은 그 목록 안, 아니면 그 메일이 든 메일함
import type { RouteLocationRaw } from 'vue-router'
import { MAIL_NO_SENDER, MAIL_NO_SUBJECT, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import type { AuthUser } from '@/shared/types/auth'
import type { Address, ConversationBasis, ConversationPage, Mailbox, MessageSummary } from '@/shared/types/mail'
import type { PageInfo } from '@/shared/types/pagination'
import { formatConversationTime, initialOf } from '@/shared/utils/format'
import { displayName, normalizeEmail } from '@/shared/utils/mail-address'
import { mailboxOfMessage, pendingSendLabel } from '@/shared/utils/mail-list-row'
import { messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
import { isScheduledMessage } from '@/shared/utils/pending-send'
const CONVERSATION_BASES: readonly string[] = ['index', 'engine']
/** 빈 대화 — 모양이 틀린 응답 (칸을 보이지 않는다) */
function emptyConversationPage(): ConversationPage {
return { items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 }, basis: null }
}
/** 줄로 쓸 수 있는 항목인가 — 줄이 읽는 칸(id · 메일함 · 받는 사람 · 제목 · 미리 보기 · 받은 시각)의 모양만 본다 (나머지는 서버의 요약을 믿는다) */
function isSummaryLike(value: unknown): value is MessageSummary {
if (typeof value !== 'object' || value === null) return false
const candidate = value as Partial<Record<keyof MessageSummary, unknown>>
return (
typeof candidate.id === 'string' &&
candidate.id !== '' &&
Array.isArray(candidate.mailboxIds) &&
Array.isArray(candidate.to) &&
typeof candidate.subject === 'string' &&
typeof candidate.preview === 'string' &&
typeof candidate.receivedAt === 'string'
)
}
/**
* GET /messages/{id}/conversation 의 응답 → ConversationPage. 모양이 틀리면(응답을 바꾼 게이트웨이 등) 빈 대화다.
* 커서가 없는 hasNext 는 다음 쪽이 없는 것으로, 음수 · 정수가 아닌 total 은 모르는 것(null)으로 본다
*/
export function conversationPageOf(value: unknown): ConversationPage {
if (typeof value !== 'object' || value === null) return emptyConversationPage()
const candidate = value as { items?: unknown; pageInfo?: unknown; basis?: unknown }
if (!Array.isArray(candidate.items) || typeof candidate.pageInfo !== 'object' || candidate.pageInfo === null) {
return emptyConversationPage()
}
const info = candidate.pageInfo as { hasNext?: unknown; nextCursor?: unknown; total?: unknown }
const nextCursor = typeof info.nextCursor === 'string' && info.nextCursor !== '' ? info.nextCursor : null
const total = typeof info.total === 'number' && Number.isInteger(info.total) && info.total >= 0 ? info.total : null
const basis =
typeof candidate.basis === 'string' && CONVERSATION_BASES.includes(candidate.basis)
? (candidate.basis as ConversationBasis)
: null
return {
items: candidate.items.filter(isSummaryLike),
pageInfo: { hasNext: info.hasNext === true && nextCursor !== null, nextCursor, total },
basis,
}
}
/** 다음 쪽 커서 — 더 없으면 null */
export function nextConversationCursor(pageInfo: PageInfo | null): string | null {
return pageInfo?.hasNext === true && pageInfo.nextCursor ? pageInfo.nextCursor : null
}
/**
* 받은 쪽을 뒤에 붙인다 — 이미 있는 메일은 건너뛴다(늦게 온 원본으로 차례가 밀렸을 수 있다). added 는 새로 붙인 수.
* 다음 쪽의 total 이 없으면(색인 방식은 첫 쪽에서만 센다) 아는 수를 남긴다
*/
export function appendConversationPage(
items: readonly MessageSummary[],
pageInfo: PageInfo | null,
page: ConversationPage,
): { items: MessageSummary[]; pageInfo: PageInfo; added: number } {
const known = new Set(items.map((item) => item.id))
const fresh = page.items.filter((item) => !known.has(item.id))
return {
items: [...items, ...fresh],
pageInfo: { ...page.pageInfo, total: page.pageInfo.total ?? pageInfo?.total ?? null },
added: fresh.length,
}
}
/** 칸 제목의 통 수 — total, 모르면 다 받았을 때(다음 쪽 없음)의 받은 수, 그것도 모르면 null("같은 대화" — 수를 뺀다) */
export function conversationCountOf(items: readonly unknown[], pageInfo: PageInfo | null): number | null {
if (!pageInfo) return null
if (pageInfo.total !== null) return pageInfo.total
return pageInfo.hasNext ? null : items.length
}
/** 칸을 보일까 — 다른 메일이 있을 때만(한 통뿐이면 칸이 없다). 수를 모르면 두 통 넘게 받았거나 더 받을 쪽이 남았을 때 */
export function hasConversation(items: readonly unknown[], pageInfo: PageInfo | null): boolean {
const count = conversationCountOf(items, pageInfo)
if (count !== null) return count > 1
return items.length > 1 || nextConversationCursor(pageInfo) !== null
}
// ── 이름 자리 ─────────────────────────────────────────────
/** 내 주소인가 — 대소문자 · 앞뒤 공백을 가리지 않는다 */
export function isMyAddress(address: Address | null | undefined, myEmail: string | null | undefined): boolean {
if (!address || !myEmail) return false
return normalizeEmail(address.email) === normalizeEmail(myEmail)
}
/** 참여자 이름 — 나면 "나", 아니면 이름(없으면 주소). 3차 "대화로 묶어 보기"의 참여자 줄("이도윤, 나")도 이것을 쓴다 */
export function participantName(address: Address | null | undefined, myEmail: string | null | undefined): string {
return isMyAddress(address, myEmail) ? MAIL_CONVERSATION_TEXT.me : displayName(address)
}
/** 그 메일이 든 보낸편지함 · 임시보관함 — 없으면(받은 메일 · 메일함을 아직 모름) null */
export function outgoingMailboxOf(
message: Pick<MessageSummary, 'mailboxIds'>,
mailboxes: readonly Mailbox[],
): Mailbox | null {
for (const id of message.mailboxIds) {
const mailbox = mailboxes.find((entry) => entry.id === id)
if (mailbox?.role && OUTGOING_MAILBOX_ROLES.includes(mailbox.role)) return mailbox
}
return null
}
function isDraftMessage(message: Pick<MessageSummary, 'mailboxIds'>, mailboxes: readonly Mailbox[]): boolean {
return outgoingMailboxOf(message, mailboxes)?.role === 'drafts'
}
export interface ConversationSender {
/** 이름 자리 — 내가 보냈으면 "나" */
name: string
/** 머리글자 — 나도 내 이름의 첫 글자 (디자인 원본 Thread14 "김") */
initial: string
/** 내가 보낸 메일 — 머리글자를 회색으로 */
mine: boolean
/** 이름 뒤 회색 글 — 보낸편지함 · 임시보관함에 든 메일이면 그 메일함 이름 */
mailboxName: string | null
}
/**
* 줄의 이름 자리 — 보낸편지함 · 임시보관함에 들었거나 보낸 사람이 나면 "나"(+ 그 메일함 이름), 아니면 보낸 사람 이름.
* 메일함을 아직 모르면 보낸 사람 주소로만 가른다(메일함 이름 없이)
*/
export function conversationSenderOf(
message: Pick<MessageSummary, 'from' | 'mailboxIds'>,
context: { me: AuthUser | null; mailboxes: readonly Mailbox[] },
): ConversationSender {
const outgoing = outgoingMailboxOf(message, context.mailboxes)
const mine = outgoing !== null || isMyAddress(message.from, context.me?.email)
const initial = initialOf(message.from?.name, message.from?.email ?? '')
return {
name: mine ? MAIL_CONVERSATION_TEXT.me : displayName(message.from) || MAIL_NO_SENDER,
initial: initial || (mine ? initialOf(context.me?.name, context.me?.email ?? '') : ''),
mine,
mailboxName: outgoing?.name ?? null,
}
}
// ── 누르면 갈 곳 ─────────────────────────────────────────────
export interface ConversationLinkContext {
/** 지금 목록 — 그 목록에 드는 메일이면 그 목록 안에서 연다 */
source: MailListSource | null
/** 지금 목록에 받아 둔 메일 — 검색 결과 · 예약됨은 받아 둔 줄에 있을 때만 그 목록 안에서 연다 */
listedIds: ReadonlySet<string>
mailboxes: readonly Mailbox[]
}
/**
* [이 메일만 열기] — 임시 저장본은 쓰기 화면에서 이어 쓴다(목록에서 누를 때와 같다). 지금 목록에 드는 메일은 그 목록 안의 주소(목록 · 검색 결과가 그대로),
* 아니면 그 메일이 든 메일함의 주소(보낸편지함의 내 메일 등 — 목록이 그 메일함으로 바뀐다). 기록에 쌓는다(RouterLink — 목록에서 메일을 여는 것과 같다)
*/
export function conversationMessageLocation(
message: Pick<MessageSummary, 'id' | 'mailboxIds'>,
context: ConversationLinkContext,
): RouteLocationRaw {
if (isDraftMessage(message, context.mailboxes)) return { name: 'mail-compose', query: { draft: message.id } }
const { source } = context
if (source?.kind === 'mailbox' && message.mailboxIds.includes(source.mailboxId)) {
return messageLocationOf(source, message.id)
}
if (source && source.kind !== 'mailbox' && context.listedIds.has(message.id)) return messageLocationOf(source, message.id)
const mailboxId = mailboxOfMessage(message, context.mailboxes)?.id ?? message.mailboxIds[0]
if (mailboxId) return messageLocationOf({ kind: 'mailbox', mailboxId }, message.id)
return source ? messageLocationOf(source, message.id) : { name: 'mail' }
}
/** [답장] — 읽기 화면의 [답장]과 같은 쓰기 화면. 임시 저장본 · 예약한 메일은 없다(읽기 화면도 예약한 메일에는 답장이 없다) */
export function conversationReplyLocation(
message: Pick<MessageSummary, 'id' | 'mailboxIds' | 'pendingSend'>,
mailboxes: readonly Mailbox[],
): RouteLocationRaw | null {
if (isDraftMessage(message, mailboxes) || isScheduledMessage(message)) return null
return { name: 'mail-compose', query: { replyTo: message.id } }
}
// ── 줄 ─────────────────────────────────────────────
export interface ConversationRow {
message: MessageSummary
/** 지금 읽기 화면에 연 메일 — "지금 보고 있는 메일", 누를 수 없다 */
current: boolean
sender: ConversationSender
/** 미리 보기 한 줄 — 비었으면 제목 */
preview: string
/** 시각 자리 — 붙잡힌 메일(보내기 취소 · 예약)은 목록과 같은 "보내는 중" · "예약 · …" */
time: string
/** <time datetime> — 붙잡힌 메일은 보낼 시각 */
timeValue: string
/** 붙잡힌 메일 — 시각 자리를 청록 굵게 (목록과 같다) */
pending: boolean
/** 펼쳤을 때의 [이 메일만 열기] */
location: RouteLocationRaw
/** 펼쳤을 때의 [답장] — 없으면 null */
replyLocation: RouteLocationRaw | null
}
export interface ConversationRowContext extends ConversationLinkContext {
currentId: string
me: AuthUser | null
/** 시각 글의 "오늘 · 어제"를 가르는 지금 */
now: Date
/** 테스트가 시간대를 넘긴다 — 주지 않으면 브라우저 시간대 */
timeZone?: string
}
/** 받은 대화 → 칸의 줄 (오래된 차례 그대로) */
export function conversationRowsOf(items: readonly MessageSummary[], context: ConversationRowContext): ConversationRow[] {
return items.map((message) => {
const pendingLabel = pendingSendLabel(message, context.timeZone)
return {
message,
current: message.id === context.currentId,
sender: conversationSenderOf(message, context),
preview: message.preview.trim() || message.subject || MAIL_NO_SUBJECT,
time: pendingLabel ?? formatConversationTime(message.receivedAt, context.now, context.timeZone),
timeValue: message.pendingSend?.sendAt ?? message.receivedAt,
pending: pendingLabel !== null,
location: conversationMessageLocation(message, context),
replyLocation: conversationReplyLocation(message, context.mailboxes),
}
})
}
+2 -2
View File
@@ -6,6 +6,7 @@
import {
DRAFT_NO_RECIPIENTS,
MAIL_NO_SENDER,
MAIL_SEARCH_TEXT,
OUTGOING_MAILBOX_ROLES,
} from '@/shared/constants/mail'
@@ -16,7 +17,6 @@ import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/
import { messagePendingSend } from '@/shared/utils/pending-send'
const NO_RECIPIENTS = '받는 사람 없음'
const NO_SENDER = '(보낸 사람 없음)'
export interface RowContext {
/** 행의 메일함 역할 — 메일함 목록 · 한 메일함 안의 검색은 그 메일함, 전체 메일함 검색은 그 메일의 메일함 */
@@ -72,7 +72,7 @@ export function rowWho(message: Pick<MessageSummary, 'from' | 'to'>, context: Ro
const prefixed = view === 'split' || mixed
return { text: prefixed ? `${MAIL_SEARCH_TEXT.recipientPrefix}${recipients}` : recipients, placeholder: false }
}
return { text: displayName(message.from) || NO_SENDER, placeholder: false }
return { text: displayName(message.from) || MAIL_NO_SENDER, placeholder: false }
}
/** 표 머리의 보낸 사람 칸 이름 — 보낸편지함 · 임시보관함(한 메일함일 때)은 받는 사람 */
@@ -0,0 +1,718 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import {
MAIL_CONVERSATION_DETAIL_CACHE_MAX,
MAIL_CONVERSATION_FILL_MAX_REQUESTS,
MAIL_CONVERSATION_PAGE_SIZE,
} from '@/shared/constants/mail-conversation'
import type { MessageDetail, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useMailStore } from './mail.store'
// 14단계 같은 대화 — 받기 · 이어 받기 · 늦은 응답 · 다시 받기 · [더 보기] · 펼친 줄의 읽음 (계약 2절 "대화 — 14단계")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: USER.email }],
subject: '회신: 유지보수 계약 갱신 건',
preview: `미리 보기 ${id}`,
receivedAt: '2026-10-29T00:30:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
...partial,
}
}
function detail(id: string, partial: Partial<MessageDetail> = {}): MessageDetail {
return {
...summary(id),
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: '<p>본문</p>',
textBody: '본문',
hasBlockedImages: false,
attachments: [],
...partial,
}
}
function conversation(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length): MockResult {
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
}
function deferred() {
let resolve: (value: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((done) => {
resolve = done
})
return { promise, resolve }
}
const THREAD = [summary('m1', { mailboxIds: ['sent'] }), summary('m2'), summary('m3')]
const ids = (list: readonly { id: string }[]) => list.map((entry) => entry.id)
/** 한 쪽(100통)이 찬 첫 쪽 — 처음 받을 때 이어 묻지 않고 [더 보기]가 남는다 (색인 방식의 큰 대화) */
function fullPage(...head: MessageSummary[]): MessageSummary[] {
const rest = Array.from({ length: MAIL_CONVERSATION_PAGE_SIZE - head.length }, (_, index) => summary(`p${index + 1}`))
return [...head, ...rest]
}
const conversationRequests = (requests: MockRequest[]) => requests.filter((request) => request.url.endsWith('/conversation'))
describe('mail.store — 14단계 같은 대화', () => {
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
function serve(handler: MockHandler) {
requests = installMockApi(handler)
}
describe('받기', () => {
it('그 메일의 대화를 알리지 않고 받는다 — 오래된 차례 그대로, N 은 total', async () => {
serve(() => conversation(THREAD))
const store = useMailStore()
await store.loadConversation('m3')
const [request] = conversationRequests(requests)
expect(request?.url).toBe('/messages/m3/conversation')
expect(request?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE })
expect(request?.config.silent).toBe(true)
expect(ids(store.conversationItems)).toEqual(['m1', 'm2', 'm3'])
expect(store.conversationCount).toBe(3)
expect(store.conversationVisible).toBe(true)
expect(store.hasMoreConversation).toBe(false)
})
it('한 통뿐이면 칸이 없다', async () => {
serve(() => conversation([summary('m3')]))
const store = useMailStore()
await store.loadConversation('m3')
expect(store.conversationStatus).toBe('ready')
expect(store.conversationVisible).toBe(false)
})
it('못 받으면 알리지 않고 칸이 안내를 그린다 — 없는 메일(404 RES_001 · 이 경로가 없는 옛 서버)은 칸 없이', async () => {
serve(() => fail(503, 'MAIL_001'))
const store = useMailStore()
await store.loadConversation('m3')
expect(store.conversationStatus).toBe('error')
expect(store.conversationFailed).toBe(true)
expect(store.conversationError?.code).toBe('MAIL_001')
serve(() => fail(404, 'RES_001'))
await store.retryConversation()
expect(store.conversationStatus).toBe('error')
expect(store.conversationFailed).toBe(false)
expect(store.conversationVisible).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('[다시 시도]는 처음부터 다시 받는다 — 받으면 칸이 보인다', async () => {
let healthy = false
serve(() => (healthy ? conversation(THREAD) : fail(503, 'MAIL_001')))
const store = useMailStore()
await store.loadConversation('m3')
healthy = true
await store.retryConversation()
expect(store.conversationFailed).toBe(false)
expect(store.conversationVisible).toBe(true)
expect(conversationRequests(requests)).toHaveLength(2)
})
it('같은 메일을 이미 받았거나 받는 중이면 다시 묻지 않는다 (다시 받기는 refreshConversation)', async () => {
const first = deferred()
serve(() => first.promise)
const store = useMailStore()
const loading = store.loadConversation('m3')
void store.loadConversation('m3')
first.resolve(conversation(THREAD))
await loading
await store.loadConversation('m3')
expect(conversationRequests(requests)).toHaveLength(1)
})
})
describe('이어 받기 — hasNext 인데 항목이 적거나 0개인 쪽 (엔진 방식)', () => {
it('지금 메일 한 통만 받고 이어 묻는 동안은 칸을 그리지 않고, 두 통 넘게 모이면 보이며 뒤에서 잇는다', async () => {
const third = deferred()
serve(({ params }) => {
if (!params.cursor) return conversation([summary('m3')], 'c2', null)
if (params.cursor === 'c2') return conversation([summary('m1'), summary('m2')], 'c3', null)
return third.promise
})
const store = useMailStore()
const loading = store.loadConversation('m3')
await flushPromises()
expect(conversationRequests(requests).map((request) => request.params.cursor)).toEqual([undefined, 'c2', 'c3'])
expect(store.conversationVisible).toBe(true)
expect(store.conversationLoadingMore).toBe(true)
// 서버의 차례대로 붙인다 — 다시 줄 세우지 않는다
expect(ids(store.conversationItems)).toEqual(['m3', 'm1', 'm2'])
// 수를 모르고 남은 쪽이 있다 — "같은 대화" (수 없이)
expect(store.conversationCount).toBeNull()
third.resolve(conversation([summary('m4')], null, null))
await loading
expect(store.conversationLoadingMore).toBe(false)
expect(store.conversationCount).toBe(4)
})
it(`빈 쪽이 이어지면 ${MAIL_CONVERSATION_FILL_MAX_REQUESTS}번까지만 묻고 멈춘다 — 남은 쪽은 [더 보기]`, async () => {
let next = 0
serve(({ params }) => {
next += 1
return params.cursor ? conversation([], `c${next + 1}`, null) : conversation([summary('m3'), summary('m2')], 'c2', null)
})
const store = useMailStore()
await store.loadConversation('m3')
expect(conversationRequests(requests)).toHaveLength(MAIL_CONVERSATION_FILL_MAX_REQUESTS)
expect(store.hasMoreConversation).toBe(true)
expect(store.conversationVisible).toBe(true)
expect(store.conversationLoadingMore).toBe(false)
})
it('한 쪽이 찼으면 더 묻지 않는다 — [더 보기]는 다음 쪽부터 같은 결로 잇고, 사용자가 누른 일이라 실패는 알린다(받은 데까지 남는다)', async () => {
let failing = false
serve(({ params }) => {
if (!params.cursor) return conversation(fullPage(summary('m1')), 'c2', 102)
if (failing) return fail(503, 'MAIL_001')
return conversation([summary('m2'), summary('m3')], null, null)
})
const store = useMailStore()
await store.loadConversation('m3')
expect(conversationRequests(requests)).toHaveLength(1)
expect(store.hasMoreConversation).toBe(true)
failing = true
await store.loadMoreConversation()
expect(conversationRequests(requests).at(-1)?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE, cursor: 'c2' })
expect(conversationRequests(requests).at(-1)?.config.silent).toBeUndefined()
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
expect(store.conversationItems).toHaveLength(MAIL_CONVERSATION_PAGE_SIZE)
expect(store.hasMoreConversation).toBe(true)
failing = false
await store.loadMoreConversation()
expect(ids(store.conversationItems).slice(-2)).toEqual(['m2', 'm3'])
expect(store.hasMoreConversation).toBe(false)
// 다음 쪽의 total 은 null — 첫 쪽의 수를 남긴다
expect(store.conversationCount).toBe(102)
})
})
describe('늦게 온 응답 · 같은 대화의 다른 메일', () => {
it('메일을 바꾸면 앞 메일의 늦은 응답은 버린다', async () => {
const late = deferred()
serve(({ url }) => (url === '/messages/m3/conversation' ? late.promise : conversation([summary('x1'), summary('x2')])))
const store = useMailStore()
const first = store.loadConversation('m3')
await store.loadConversation('x2')
late.resolve(conversation(THREAD))
await first
expect(store.conversationMessageId).toBe('x2')
expect(ids(store.conversationItems)).toEqual(['x1', 'x2'])
})
it('같은 대화의 다른 메일로 옮기면 줄을 둔 채 그 메일로 새로 받는다 — 옛 커서는 쓰지 않는다([더 보기]를 내린다)', async () => {
const second = deferred()
const first = fullPage(...THREAD)
serve(({ url }) => (url === '/messages/m3/conversation' ? conversation(first, 'old', null) : second.promise))
const store = useMailStore()
await store.loadConversation('m3')
expect(store.hasMoreConversation).toBe(true)
const moving = store.loadConversation('m2')
expect(store.conversationMessageId).toBe('m2')
expect(store.conversationVisible).toBe(true)
expect(ids(store.conversationItems)).toEqual(ids(first))
expect(store.hasMoreConversation).toBe(false)
// 남은 쪽이 있다는 것은 그대로 — 받은 수(100)를 통 수로 짐작하지 않는다
expect(store.conversationCount).toBeNull()
second.resolve(conversation([...THREAD, summary('m4')], null, 4))
await moving
expect(conversationRequests(requests).at(-1)?.url).toBe('/messages/m2/conversation')
expect(conversationRequests(requests).at(-1)?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE })
expect(ids(store.conversationItems)).toEqual(['m1', 'm2', 'm3', 'm4'])
})
it('열린 메일을 닫으면(주소에 메일이 없다) 칸을 비운다 — 늦은 응답도 버린다', async () => {
const late = deferred()
serve(() => late.promise)
const store = useMailStore()
const loading = store.loadConversation('m3')
store.closeMessage()
late.resolve(conversation(THREAD))
await loading
expect(store.conversationMessageId).toBeNull()
expect(store.conversationItems).toEqual([])
expect(store.conversationStatus).toBe('idle')
})
it('세션이 바뀌면(로그아웃 · 다른 계정) 비우고 늦은 응답을 버린다', async () => {
const late = deferred()
serve(() => late.promise)
const store = useMailStore()
const loading = store.loadConversation('m3')
useAuthStore().clearAuth()
useAuthStore().startSession({ email: 'seojun.park@arasystem.test', name: '박서준' })
await flushPromises()
late.resolve(conversation(THREAD))
await loading
expect(store.conversationMessageId).toBeNull()
expect(store.conversationItems).toEqual([])
expect(store.conversationStatus).toBe('idle')
})
})
describe('다시 받기 (바뀜 소식 · 쓰기 화면에서 돌아옴)', () => {
it('보이는 채로 처음 쪽부터 다시 받아 한 번에 바꾼다 — 실패는 알리지 않고 그대로 둔다', async () => {
let thread = THREAD
let failing = false
serve(() => (failing ? fail(503, 'MAIL_001') : conversation(thread)))
const store = useMailStore()
await store.loadConversation('m3')
thread = [...THREAD, summary('m4', { mailboxIds: ['sent'] })]
await store.refreshConversation()
expect(ids(store.conversationItems)).toEqual(['m1', 'm2', 'm3', 'm4'])
expect(conversationRequests(requests).at(-1)?.config.silent).toBe(true)
failing = true
await store.refreshConversation()
expect(ids(store.conversationItems)).toEqual(['m1', 'm2', 'm3', 'm4'])
expect(useToastStore().toasts).toEqual([])
})
it('한 번에 하나 — 받는 사이 온 요청은 여럿이어도 끝난 뒤 한 번만 더 받는다', async () => {
const replies: ReturnType<typeof deferred>[] = []
serve(() => {
if (replies.length === 0) return conversation(THREAD)
const reply = deferred()
replies.push(reply)
return reply.promise
})
const store = useMailStore()
await store.loadConversation('m3')
replies.push(deferred())
const refreshing = store.refreshConversation()
void store.refreshConversation()
void store.refreshConversation()
await flushPromises()
expect(conversationRequests(requests)).toHaveLength(2)
replies[1]?.resolve(conversation(THREAD))
await flushPromises()
expect(conversationRequests(requests)).toHaveLength(3)
replies[2]?.resolve(conversation(THREAD))
await refreshing
expect(conversationRequests(requests)).toHaveLength(3)
})
it('[더 보기]로 더 받은 대화는 저절로 다시 받지 않는다 — 받아 둔 줄이 처음 쪽으로 줄지 않게', async () => {
serve(({ params }) =>
params.cursor ? conversation([summary('m3')], null, null) : conversation(fullPage(summary('m1')), 'c2', 101),
)
const store = useMailStore()
await store.loadConversation('m3')
await store.loadMoreConversation()
const sent = conversationRequests(requests).length
expect(sent).toBe(2)
await store.refreshConversation()
expect(conversationRequests(requests)).toHaveLength(sent)
expect(store.conversationItems).toHaveLength(MAIL_CONVERSATION_PAGE_SIZE + 1)
})
it('붙잡힌 메일(보내기 취소 · 예약)이 든 대화만 엔진이 풀 때 다시 받는다 (refreshPendingSends)', async () => {
const held = summary('m4', {
mailboxIds: ['sent'],
pendingSend: { kind: 'undo', sendAt: '2026-10-29T01:00:10Z' },
})
let thread = THREAD
serve(({ url }) => {
if (url.endsWith('/conversation')) return conversation(thread)
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
})
const store = useMailStore()
await store.loadConversation('m3')
await store.refreshPendingSends()
expect(conversationRequests(requests)).toHaveLength(1)
thread = [...THREAD, held]
await store.refreshConversation()
thread = [...THREAD, { ...held, pendingSend: null }]
await store.refreshPendingSends()
expect(conversationRequests(requests)).toHaveLength(3)
expect(store.conversationItems.at(-1)?.pendingSend).toBeNull()
})
})
describe('펼친 줄 — 검색 쿼리 없이 받고, 안 읽은 메일이면 읽음으로', () => {
function serveThread(items: MessageSummary[], options: { detailReply?: () => MockResult | Promise<MockResult>; patch?: () => MockResult } = {}) {
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return conversation(items)
if (method === 'PATCH') return options.patch?.() ?? ok({ ...summary('m2'), isRead: true })
return options.detailReply?.() ?? ok(detail(url.split('/').pop() ?? 'm2', { isRead: false }))
})
}
const patches = () => requests.filter((request) => request.method === 'PATCH')
const details = () =>
requests.filter(
(request) => request.method === 'GET' && request.url.startsWith('/messages/') && !request.url.endsWith('/conversation'),
)
it('펴면 그 메일을 쿼리 없이(강조 없이) 알리지 않고 받고, 안 읽은 메일이면 한 번만 읽음으로 — 접었다 다시 펴도 다시 받지 않는다', async () => {
serveThread([summary('m1'), summary('m2', { isRead: false }), summary('m3')])
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
expect(store.conversationExpandedIds.has('m2')).toBe(true)
expect(store.conversationDetails.get('m2')?.status).toBe('loading')
await flushPromises()
expect(details().map((request) => [request.url, request.params, request.config.silent])).toEqual([
['/messages/m2', {}, true],
])
expect(store.conversationDetails.get('m2')?.status).toBe('ready')
expect(patches().map((request) => [request.url, request.data])).toEqual([['/messages/m2', { isRead: true }]])
expect(store.conversationItems.find((item) => item.id === 'm2')?.isRead).toBe(true)
store.toggleConversationMessage('m2')
expect(store.conversationExpandedIds.has('m2')).toBe(false)
store.toggleConversationMessage('m2')
await flushPromises()
expect(details()).toHaveLength(1)
expect(patches()).toHaveLength(1)
})
it('지금 메일의 줄은 펴지 않는다', async () => {
serveThread(THREAD)
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m3')
expect(store.conversationExpandedIds.size).toBe(0)
expect(details()).toEqual([])
})
it('본문이 오기 전에 접었으면 읽음으로 바꾸지 않는다 (읽지 않았다)', async () => {
const reply = deferred()
serveThread([summary('m2', { isRead: false }), summary('m3')], { detailReply: () => reply.promise })
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
store.toggleConversationMessage('m2')
reply.resolve(ok(detail('m2', { isRead: false })))
await flushPromises()
expect(store.conversationDetails.get('m2')?.status).toBe('ready')
expect(patches()).toEqual([])
})
it('받아 둔 대화가 옛 값이어도 이 탭이 읽음으로 아는 메일(보이는 목록 · 열린 메일)은 다시 읽음으로 바꾸지 않는다', async () => {
serveThread([summary('m1', { isRead: false }), summary('m2', { isRead: false }), summary('m3')])
const store = useMailStore()
store.messages = [summary('m1', { isRead: true })]
store.selected = detail('m2', { isRead: true })
await store.loadConversation('m3')
store.toggleConversationMessage('m1')
store.toggleConversationMessage('m2')
await flushPromises()
expect(patches()).toEqual([])
expect(store.conversationItems.filter((item) => !item.isRead)).toEqual([])
})
it('읽음으로 바꾸지 못하면 이 칸의 표시도 되돌린다 — 다시 펴면 다시 해 본다', async () => {
let rejecting = true
serveThread([summary('m2', { isRead: false }), summary('m3')], {
patch: () => (rejecting ? fail(503, 'MAIL_001') : ok({ ...summary('m2'), isRead: true })),
})
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
await flushPromises()
expect(store.conversationItems.find((item) => item.id === 'm2')?.isRead).toBe(false)
rejecting = false
store.toggleConversationMessage('m2')
store.toggleConversationMessage('m2')
await flushPromises()
expect(patches()).toHaveLength(2)
expect(store.conversationItems.find((item) => item.id === 'm2')?.isRead).toBe(true)
})
it('본문을 못 받으면 줄 안에 오류 — [다시 시도]로 다시 받는다', async () => {
let failing = true
serveThread([summary('m2'), summary('m3')], {
detailReply: () => (failing ? fail(503, 'MAIL_001') : ok(detail('m2'))),
})
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
await flushPromises()
const state = store.conversationDetails.get('m2')
expect(state?.status === 'error' ? state.error.code : null).toBe('MAIL_001')
expect(useToastStore().toasts).toEqual([])
failing = false
await store.retryConversationDetail('m2')
expect(store.conversationDetails.get('m2')?.status).toBe('ready')
})
it('같은 대화의 다른 메일로 옮기면 그 줄(이제 지금 메일)의 펼침을 접고, 읽기 화면이 본 앞 메일은 읽음으로 본다', async () => {
serveThread([summary('m2'), summary('m3', { isRead: false })])
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
await flushPromises()
await store.loadConversation('m2')
expect(store.conversationExpandedIds.has('m2')).toBe(false)
store.toggleConversationMessage('m3')
await flushPromises()
expect(patches()).toEqual([])
})
it('본문을 받는 사이 세션이 바뀌면(로그아웃 뒤 다시 로그인) 버리고 읽음으로 바꾸지 않는다', async () => {
const reply = deferred()
serveThread([summary('m2', { isRead: false }), summary('m3')], { detailReply: () => reply.promise })
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
useAuthStore().clearAuth()
useAuthStore().startSession(USER)
reply.resolve(ok(detail('m2', { isRead: false })))
await flushPromises()
expect(patches()).toEqual([])
expect(store.conversationDetails.get('m2')?.status).not.toBe('ready')
})
it(`접힌 줄의 받아 둔 본문은 최근 ${MAIL_CONVERSATION_DETAIL_CACHE_MAX}개만 둔다 — 펼친 줄의 본문은 세지 않고 늘 두며, 버린 본문은 다시 펴면 다시 받는다`, async () => {
const others = Array.from({ length: MAIL_CONVERSATION_DETAIL_CACHE_MAX + 3 }, (_, index) => summary(`r${index}`))
serveThread([...others, summary('m3')])
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('r0')
await flushPromises()
for (let index = 1; index < others.length; index += 1) {
store.toggleConversationMessage(`r${index}`)
await flushPromises()
store.toggleConversationMessage(`r${index}`)
}
const cached = [...store.conversationDetails.keys()]
expect(cached[0]).toBe('r0')
expect(cached.slice(1)).toEqual(
Array.from({ length: MAIL_CONVERSATION_DETAIL_CACHE_MAX }, (_, index) => `r${index + 3}`),
)
expect(store.conversationExpandedIds.has('r0')).toBe(true)
const fetched = details().length
store.toggleConversationMessage(`r${others.length - 1}`)
await flushPromises()
expect(details()).toHaveLength(fetched)
store.toggleConversationMessage('r1')
await flushPromises()
expect(details()).toHaveLength(fetched + 1)
})
})
describe('다시 받아 달라는 소식 — 메일함들의 통 수가 바뀔 때만', () => {
const box = (id: string, totalCount: number, unreadCount: number) => ({
id,
name: id,
role: null,
parentId: null,
sortOrder: 0,
totalCount,
unreadCount,
})
it('처음 받은 메일함 목록 · 안 읽은 수만 바뀜 · 차례만 바뀜에는 오르지 않고, 새 메일 · 옮김으로 통 수가 바뀌면 오른다 — 비울 때도 오르지 않는다', async () => {
serve(() => ok(null))
const store = useMailStore()
const seq = () => store.conversationRefreshSeq
store.mailboxes = [box('inbox', 3, 1), box('sent', 1, 0)]
await flushPromises()
expect(seq()).toBe(0)
store.mailboxes = [box('inbox', 3, 0), box('sent', 1, 0)]
await flushPromises()
expect(seq()).toBe(0)
store.mailboxes = [box('inbox', 4, 0), box('sent', 1, 0)]
await flushPromises()
expect(seq()).toBe(1)
store.mailboxes = [box('inbox', 3, 0), box('sent', 2, 0)]
await flushPromises()
expect(seq()).toBe(2)
store.mailboxes = [box('sent', 2, 0), box('inbox', 3, 0)]
await flushPromises()
expect(seq()).toBe(2)
store.reset()
await flushPromises()
expect(seq()).toBe(2)
})
})
describe('묻는 중 · 붙잡힌 메일 · 그사이 지워진 메일', () => {
it('묻는 동안 conversationBusy — 끝나면 꺼진다 (칸이 그때부터 다시 받기의 간격을 센다)', async () => {
const reply = deferred()
serve(() => reply.promise)
const store = useMailStore()
const loading = store.loadConversation('m3')
expect(store.conversationBusy).toBe(true)
reply.resolve(conversation(THREAD))
await loading
expect(store.conversationBusy).toBe(false)
// 받을 것이 없으면(받은 대화가 없음) 묻지 않는다 — 켜지지 않는다
store.reset()
await store.refreshConversation()
expect(store.conversationBusy).toBe(false)
})
it('[더 보기]로 더 받은 대화는 붙잡힌 메일만 그 메일로 고친다 — 다 보냈으면 표시가 내려가고, 지워졌으면(404) 그 줄을 뺀다', async () => {
const sendAt = '2026-10-29T01:00:10Z'
const sent = summary('h1', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt } })
const gone = summary('h2', { mailboxIds: ['sent'], pendingSend: { kind: 'scheduled', sendAt } })
serve(({ url, params }) => {
if (url.endsWith('/conversation')) {
return params.cursor ? conversation([sent, gone, summary('m3')], null, null) : conversation(fullPage(summary('m1')), 'c2', 103)
}
if (url === '/messages/h1') return ok(detail('h1', { mailboxIds: ['sent'], pendingSend: null, htmlBody: '<p>긴 본문</p>' }))
if (url === '/messages/h2') return fail(404, 'RES_001')
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
})
const store = useMailStore()
await store.loadConversation('m3')
await store.loadMoreConversation()
const sentConversations = conversationRequests(requests).length
await store.refreshPendingSends()
expect(conversationRequests(requests)).toHaveLength(sentConversations)
expect(
requests.filter((request) => request.url === '/messages/h1' || request.url === '/messages/h2').map((request) => [request.url, request.config.silent]),
).toEqual([
['/messages/h1', true],
['/messages/h2', true],
])
const fixed = store.conversationItems.find((item) => item.id === 'h1')
expect(fixed?.pendingSend).toBeNull()
// 줄에는 요약만 — 본문을 싣지 않는다
expect(fixed && 'htmlBody' in fixed).toBe(false)
expect(store.conversationItems.some((item) => item.id === 'h2')).toBe(false)
expect(store.conversationCount).toBe(102)
expect(useToastStore().toasts).toEqual([])
})
it('[더 보기]로 더 받은 대화에서 펼친 본문이 404(그사이 지워짐)면 다시 받지 않고 그 줄만 뺀다', async () => {
serve(({ url, params }) => {
if (url.endsWith('/conversation')) {
return params.cursor ? conversation([summary('x1'), summary('m3')], null, null) : conversation(fullPage(summary('m1')), 'c2', 102)
}
return fail(404, 'RES_001')
})
const store = useMailStore()
await store.loadConversation('m3')
await store.loadMoreConversation()
const sentConversations = conversationRequests(requests).length
store.toggleConversationMessage('x1')
await flushPromises()
expect(conversationRequests(requests)).toHaveLength(sentConversations)
expect(store.conversationItems.some((item) => item.id === 'x1')).toBe(false)
expect(store.conversationExpandedIds.has('x1')).toBe(false)
expect(store.conversationCount).toBe(101)
})
it('같은 메일을 읽음으로 보내는 중이면 다시 보내지 않는다 — 안 읽은 수를 두 번 빼지 않게', async () => {
const patch = deferred()
serve(({ method }) => (method === 'PATCH' ? patch.promise : ok(null)))
const store = useMailStore()
store.mailboxes = [{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 2 }]
store.mailboxesStatus = 'ready'
const message = { id: 'm2', isRead: false, mailboxIds: ['inbox'] }
const first = store.markRead(message)
expect(await store.markRead(message)).toBe(true)
patch.resolve(ok({ ...summary('m2'), isRead: true }))
expect(await first).toBe(true)
expect(requests.filter((request) => request.method === 'PATCH')).toHaveLength(1)
expect(store.mailboxes[0]?.unreadCount).toBe(1)
})
})
})
+71 -3
View File
@@ -1,9 +1,10 @@
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { useMail } from '@/composables/useMail'
import { useAuthStore } from '@/stores/auth.store'
import { createBulkActions } from './mail/bulk'
import { createConversationState } from './mail/conversation'
import { createListState } from './mail/list'
import { createMailboxAdmin } from './mail/mailbox-admin'
import { createMailboxesState } from './mail/mailboxes'
@@ -14,6 +15,7 @@ import { createSelection } from './mail/selection'
export type { BulkResult } from '@/shared/utils/mail-bulk'
export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, MoveOptions, SearchOrigin } from './mail/types'
export type { MailboxOperationOutcome, MailboxOperationProgress } from './mail/mailbox-admin'
export type { ConversationDetailState } from './mail/conversation'
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 · 검색 결과 · 예약됨) · 고른 메일 · 열린 메일
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
@@ -21,6 +23,7 @@ export type { MailboxOperationOutcome, MailboxOperationProgress } from './mail/m
// message.ts 열린 메일 · 읽음 · 중요 표시 · 외부 이미지 보기 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
// mailbox-admin.ts 메일함 만들기 · 이름 바꾸기 · 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기)
// scheduled.ts 예약됨 개수 · 붙잡은 발송 취소(보내기 취소 · 예약 취소 — 9단계) · 엔진이 풀었는지 다시 보기
// conversation.ts 읽기 화면의 같은 대화 · 펼친 줄의 본문 (14단계)
//
// 오류 처리 원칙:
// - 화면 영역이 오류 상태를 직접 그리는 첫 조회(메일함·목록·본문)는 silent 로 부르고 상태에 담는다
@@ -48,6 +51,48 @@ export const useMailStore = defineStore('mail', () => {
opened,
sessionGeneration: () => authStore.generation,
})
const conversation = createConversationState({
mailApi,
list,
opened,
markRead: message.markRead,
sessionGeneration: () => authStore.generation,
})
/**
* 엔진이 붙잡은 발송을 풀었는지 다시 본다 — 보이는 목록 · 열린 메일 · 예약됨 수(scheduled.ts)와
* 같은 대화에 붙잡힌 메일이 있으면 그 칸도 (14단계 — 풀어도 바뀜 소식이 오지 않는다, usePendingSendRefresh)
*/
async function refreshPendingSends(): Promise<void> {
await Promise.all([scheduled.refreshPendingSends(), conversation.refreshHeldConversation()])
}
/** 열린 메일을 닫는다(주소에 메일이 없다) — 같은 대화 칸도 비운다. 칸이 없는 동안 받아 둔 대화를 다시 받거나 지켜보지 않게 */
function closeMessage() {
opened.closeMessage()
conversation.reset()
}
/**
* 메일함들의 통 수(totalCount)를 메일함마다 — 같은 대화 칸을 다시 받을지 가르는 기준 (14단계).
* 새 메일 · 지움 · 옮김은 어느 메일함의 통 수를 바꾸고, 읽음 · 중요 표시는 바꾸지 않는다(안 읽은 수만 바뀐다).
* 메일함 목록은 새 메일 확인 · 실시간 소식(MailLayout 의 syncCounts) · 이 탭의 옮기기 · 지우기 · 보내기 뒤에 다시 받는다
*/
const mailboxTotalsKey = computed(() => {
// 모양이 틀린 메일함 응답(배열이 아님)이면 비교하지 않는다 — 앞 값이 없을 때처럼 건너뛴다
const list: unknown = mailboxState.mailboxes.value
if (!Array.isArray(list)) return ''
return mailboxState.mailboxes.value
.map((mailbox) => `${mailbox.id}:${mailbox.totalCount}`)
.sort()
.join('|')
})
// 통 수가 바뀌면 같은 대화 칸을 다시 받게 한다 — 읽음 · 중요 표시만 바뀐 소식으로는 받지 않는다(칸은 읽음을 그리지 않는다).
// 처음 받은 메일함 목록 · 비울 때(로그아웃)는 견줄 앞 값이 없어 건너뛴다. 칸이 없으면 소식만 오르고 아무도 받지 않는다
watch(mailboxTotalsKey, (next, previous) => {
if (next && previous) conversation.requestConversationRefresh()
})
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일·검색 결과) */
const lastBrowseLocation = ref<string | null>(null)
@@ -83,6 +128,7 @@ export const useMailStore = defineStore('mail', () => {
function reset() {
admin.reset()
scheduled.reset()
conversation.reset()
mailboxState.reset()
list.reset()
selection.clearChecked()
@@ -156,7 +202,7 @@ export const useMailStore = defineStore('mail', () => {
selectedHighlight: opened.selectedHighlight,
imagesLoadingId: opened.imagesLoadingId,
openMessage: message.openMessage,
closeMessage: opened.closeMessage,
closeMessage,
markRead: message.markRead,
showImages: message.showImages,
applyLargeFileLink: message.applyLargeFileLink,
@@ -169,7 +215,29 @@ export const useMailStore = defineStore('mail', () => {
loadScheduledCount: scheduled.loadScheduledCount,
cancelPendingSend: scheduled.cancelPendingSend,
isCancellingPendingSend: scheduled.isCancellingPendingSend,
refreshPendingSends: scheduled.refreshPendingSends,
refreshPendingSends,
// 같은 대화 (14단계)
conversationMessageId: conversation.conversationMessageId,
conversationItems: conversation.conversationItems,
conversationPageInfo: conversation.conversationPageInfo,
conversationStatus: conversation.conversationStatus,
conversationError: conversation.conversationError,
conversationLoadingMore: conversation.conversationLoadingMore,
conversationRefreshSeq: conversation.conversationRefreshSeq,
conversationExpandedIds: conversation.conversationExpandedIds,
conversationDetails: conversation.conversationDetails,
conversationCount: conversation.conversationCount,
hasMoreConversation: conversation.hasMoreConversation,
conversationVisible: conversation.conversationVisible,
conversationFailed: conversation.conversationFailed,
conversationBusy: conversation.conversationBusy,
loadConversation: conversation.loadConversation,
retryConversation: conversation.retryConversation,
refreshConversation: conversation.refreshConversation,
requestConversationRefresh: conversation.requestConversationRefresh,
loadMoreConversation: conversation.loadMoreConversation,
toggleConversationMessage: conversation.toggleConversationMessage,
retryConversationDetail: conversation.retryConversationDetail,
// 여러 통 처리 · 한 통 삭제
patchChecked: bulk.patchChecked,
moveMessages: bulk.moveMessages,
+522
View File
@@ -0,0 +1,522 @@
// 메일 스토어 모듈 — 읽기 화면의 "같은 대화" (14단계 — 계약 2절 "대화 — 14단계", mail.store.ts 가 조립한다)
// 읽기 화면에 연 메일 하나의 같은 대화(GET /messages/{id}/conversation — 오래된 차례)를 받아 둔다. 칸은 MessageConversation, 받는 때는 useConversationSync 가 정한다.
// - 받기(loadConversation): 같은 대화의 다른 메일로 옮기면 보이던 줄을 둔 채 그 메일로 새로 받는다 — 커서는 그 메일에서만 통해 다 받을 때까지 [더 보기]를 내린다.
// 처음 받을 때는 두 통 넘게 모이는 대로 보이며 뒤에서 잇는다
// - 다시 받기(refreshConversation — 메일함들의 통 수가 바뀜 · 쓰기 화면에서 돌아옴): 보이는 채로 처음 쪽부터 다시 받아 한 번에 바꾼다.
// 한 번에 하나 — 그사이 온 요청은 끝난 뒤 한 번 더. [더 보기]로 더 받은 대화는 저절로 다시 받지 않는다(받아 둔 줄이 처음 쪽으로 줄지 않게)
// - 붙잡힌 메일(보내기 취소 · 예약)은 엔진이 풀어도 바뀜 소식이 오지 않는다 — 보낼 시각이 지나면 다시 받는다(refreshHeldConversation — usePendingSendRefresh).
// [더 보기]로 더 받은 대화는 그 메일만 GET /messages/{id} 로 고친다
// - 이어 받기: 엔진 방식은 hasNext 인데 항목이 적거나 0개인 쪽이 온다 — 새로 한 쪽(100통)이 차거나 더 없을 때까지 MAIL_CONVERSATION_FILL_MAX_REQUESTS 번까지 묻고,
// 남으면 [더 보기](loadMoreConversation)
// - 묻는 동안은 conversationBusy — 칸이 받기가 끝난 때부터 다시 받기의 간격을 센다(useConversationSync)
// 늦게 온 응답 막기: 받는 일마다 순번(runSeq — 받기 · 다시 받기 · 더 받기), 다른 대화로 바꾸거나 비울 때 대화 순번(conversationSeq), 세션 세대.
// 오류: 받기는 알리지 않고(silent) 칸이 "같은 대화를 불러오지 못했습니다" + [다시 시도]를 그린다 — 없는 메일(404 RES_001 · 이 경로가 없는 옛 서버)은 칸 없이.
// 다시 받기 · 처음 받을 때 이어 받기의 실패는 알리지 않고 받은 데까지 둔다. [더 보기]는 사용자가 누른 일이라 인터셉터가 알린다.
// 펼친 줄(toggleConversationMessage): 그 메일을 검색 쿼리 없이 받아 둔다(칸에는 강조가 없다). 받았을 때 아직 펼쳐 있고 안 읽은 메일이면 읽기 화면에서 연 것과
// 같은 길(markRead — 목록 · 메일함 개수도 맞춘다)로 읽음. 이 탭이 아는 값(열린 메일 · 보이는 목록 · 이 칸이 읽음으로 바꾼 것)을 먼저 본다 —
// 받아 둔 대화가 옛 값이어도 같은 메일을 두 번 읽음으로 바꾸지 않게. 받아 둔 본문은 접힌 줄의 것을 최근 MAIL_CONVERSATION_DETAIL_CACHE_MAX 개만 둔다.
// 그사이 지워진 메일(본문이 404 RES_001)은 대화를 다시 받아 그 줄을 뺀다([더 보기]로 더 받은 대화는 그 줄만 뺀다)
import { computed, ref, shallowRef } from 'vue'
import { toApiError, type ApiError } from '@/composables/useApi'
import type { MailRequestOptions } from '@/composables/useMail'
import {
MAIL_CONVERSATION_DETAIL_CACHE_MAX,
MAIL_CONVERSATION_FILL_MAX_REQUESTS,
MAIL_CONVERSATION_PAGE_SIZE,
} from '@/shared/constants/mail-conversation'
import type { MessageDetail, MessageSummary } from '@/shared/types/mail'
import type { PageInfo } from '@/shared/types/pagination'
import {
appendConversationPage,
conversationCountOf,
hasConversation,
nextConversationCursor,
} from '@/shared/utils/mail-conversation'
import { isHeldMessage, pendingSendOf } from '@/shared/utils/pending-send'
import type { ListState } from './list'
import type { OpenedMessage } from './message'
import type { LoadStatus, MailApi } from './types'
/** 펼친 줄의 본문 — 받는 중 · 받음 · 못 받음 */
export type ConversationDetailState =
| { status: 'loading' }
| { status: 'ready'; detail: MessageDetail }
| { status: 'error'; error: ApiError }
/** 모은 쪽 */
interface Gathered {
items: MessageSummary[]
pageInfo: PageInfo
}
interface ConversationDeps {
mailApi: MailApi
list: ListState
opened: OpenedMessage
/** 읽기 화면에서 연 것과 같은 읽음 처리 (message.ts) — 읽음이 됐으면(이미 읽음 포함) true */
markRead: (message: Pick<MessageSummary, 'id' | 'isRead' | 'mailboxIds'>) => Promise<boolean>
/** 지금 세션의 세대 (authStore.generation) — 기다리는 사이 세션이 바뀌었으면 늦은 응답을 버린다 */
sessionGeneration: () => number
}
/** 받은 메일의 요약 칸만 — 줄에 본문을 싣지 않는다 (붙잡힌 메일을 그 메일로 고칠 때) */
function summaryOf(detail: MessageDetail): MessageSummary {
return {
id: detail.id,
threadId: detail.threadId,
mailboxIds: detail.mailboxIds,
from: detail.from,
to: detail.to,
subject: detail.subject,
preview: detail.preview,
receivedAt: detail.receivedAt,
isRead: detail.isRead,
isFlagged: detail.isFlagged,
hasAttachment: detail.hasAttachment,
size: detail.size,
pendingSend: pendingSendOf(detail.pendingSend),
}
}
export function createConversationState({ mailApi, list, opened, markRead, sessionGeneration }: ConversationDeps) {
/** 칸이 가리키는 메일 — 읽기 화면에 연 메일 */
const conversationMessageId = ref<string | null>(null)
/** 같은 대화 — 오래된 차례, 지금 메일도 든다 */
const conversationItems = ref<MessageSummary[]>([])
const conversationPageInfo = ref<PageInfo | null>(null)
const conversationStatus = ref<LoadStatus>('idle')
const conversationError = ref<ApiError | null>(null)
/** 뒤에서 이어 받는 중 — 처음 받을 때의 이어 받기 · [더 보기] */
const conversationLoadingMore = ref<boolean>(false)
/** 다시 받아 달라는 소식의 차례 — 메일함들의 통 수가 바뀌면 오르고(mail.store.ts) useConversationSync 가 모아 받는다 */
const conversationRefreshSeq = ref<number>(0)
/** 펼친 줄 */
const conversationExpandedIds = ref<ReadonlySet<string>>(new Set())
/** 받아 둔 본문 — 넣은 차례가 최근에 본 차례다(접힌 줄의 것은 최근 MAIL_CONVERSATION_DETAIL_CACHE_MAX 개만). 다른 대화로 바꾸면 비운다 */
const conversationDetails = shallowRef<ReadonlyMap<string, ConversationDetailState>>(new Map())
/** 지금 대화를 묻는 요청 수 — 앞 메일의 늦은 요청도 끝날 때까지 센다 */
const conversationInFlight = ref<number>(0)
/** 칸 제목의 통 수 — 모르면 null */
const conversationCount = computed<number | null>(() =>
conversationCountOf(conversationItems.value, conversationPageInfo.value),
)
/** 받을 쪽이 남았다 — [더 보기] */
const hasMoreConversation = computed<boolean>(() => nextConversationCursor(conversationPageInfo.value) !== null)
/** 칸을 보인다 — 받았고 다른 메일이 있다 (한 통뿐이면 칸이 없다) */
const conversationVisible = computed<boolean>(
() =>
conversationStatus.value === 'ready' && hasConversation(conversationItems.value, conversationPageInfo.value),
)
/** 받지 못했다 — 칸 자리에 짧은 안내 + [다시 시도]. 없는 메일(RES_001)이면 칸 없이 */
const conversationFailed = computed<boolean>(
() => conversationStatus.value === 'error' && conversationError.value?.code !== 'RES_001',
)
/** 대화를 묻는 중 — 끝나면 칸이 다시 받기의 간격을 그때부터 센다 (실제로 물었을 때만 켜진다) */
const conversationBusy = computed<boolean>(() => conversationInFlight.value > 0)
let runSeq = 0
let conversationSeq = 0
/** [더 보기]로 더 받았다 — 저절로 다시 받지 않는다 */
let extended = false
let refreshing: Promise<void> | null = null
let refreshAgain = false
/** 이 칸이 읽음으로 바꿨거나 읽기 화면에서 본 메일 — 받아 둔 대화가 옛 값이어도 읽음으로 본다 */
const markedRead = new Set<string>()
/** 받는 일 하나의 확인 — 그 뒤 다른 일이 시작됐거나 · 다른 대화 · 세션이 바뀌었으면 false */
function liveCheck(messageId: string): () => boolean {
const seq = runSeq
const conversation = conversationSeq
const session = sessionGeneration()
return () =>
seq === runSeq &&
conversation === conversationSeq &&
session === sessionGeneration() &&
conversationMessageId.value === messageId
}
/** 묻는 동안 conversationBusy 를 켠다 */
async function tracked<T>(work: () => Promise<T>): Promise<T> {
conversationInFlight.value += 1
try {
return await work()
} finally {
conversationInFlight.value -= 1
}
}
/**
* 쪽을 받아 모은다 — from 이 없으면 처음 쪽부터, 있으면 그 다음 쪽부터. 새로 한 쪽이 차거나 · 더 없거나 · 요청이 FILL_MAX 번이 될 때까지.
* 쪽마다 onPage(지금까지 모은 것, 더 묻는지). 그사이 다른 일이 시작됐으면 null — 받은 것을 쓰지 않는다. 실패는 그대로 던진다
*/
function gather(
messageId: string,
from: Gathered | null,
live: () => boolean,
options: MailRequestOptions,
onPage?: (gathered: Gathered, continuing: boolean) => void,
): Promise<Gathered | null> {
let cursor = from ? nextConversationCursor(from.pageInfo) : null
if (from && cursor === null) return Promise.resolve(from)
return tracked(async () => {
let items: MessageSummary[] = from ? [...from.items] : []
let pageInfo: PageInfo | null = from?.pageInfo ?? null
let added = 0
let requests = 0
let continuing = true
while (continuing) {
const page = await mailApi.getConversation(
messageId,
{ limit: MAIL_CONVERSATION_PAGE_SIZE, ...(cursor ? { cursor } : {}) },
options,
)
requests += 1
if (!live()) return null
const merged = appendConversationPage(items, pageInfo, page)
items = merged.items
pageInfo = merged.pageInfo
added += merged.added
cursor = nextConversationCursor(pageInfo)
continuing =
cursor !== null && added < MAIL_CONVERSATION_PAGE_SIZE && requests < MAIL_CONVERSATION_FILL_MAX_REQUESTS
onPage?.({ items, pageInfo }, continuing)
}
return pageInfo ? { items, pageInfo } : null
})
}
/** 받은 쪽을 보인다 — 대화에서 빠진 메일의 펼침은 접는다 */
function show(gathered: Gathered) {
conversationItems.value = gathered.items
conversationPageInfo.value = gathered.pageInfo
const ids = new Set(gathered.items.map((item) => item.id))
const expanded = [...conversationExpandedIds.value]
if (expanded.some((id) => !ids.has(id))) conversationExpandedIds.value = new Set(expanded.filter((id) => ids.has(id)))
}
/** 받아 둔 본문을 고친 뒤, 접힌 줄의 것이 상한을 넘으면 오래 보지 않은 것부터 버린다 (펼친 줄의 것은 늘 둔다) */
function setDetails(update: (details: Map<string, ConversationDetailState>) => void) {
const next = new Map(conversationDetails.value)
update(next)
const collapsed = [...next.keys()].filter((id) => !conversationExpandedIds.value.has(id))
for (const id of collapsed.slice(0, Math.max(0, collapsed.length - MAIL_CONVERSATION_DETAIL_CACHE_MAX))) {
next.delete(id)
}
conversationDetails.value = next
}
/** 그 줄의 본문 — 가장 최근에 본 것으로 둔다(Map 의 끝으로) */
function setDetail(id: string, state: ConversationDetailState) {
setDetails((details) => {
details.delete(id)
details.set(id, state)
})
}
function collapse(id: string) {
if (!conversationExpandedIds.value.has(id)) return
const next = new Set(conversationExpandedIds.value)
next.delete(id)
conversationExpandedIds.value = next
// 방금 접은 줄의 본문이 가장 최근이다 — 상한을 넘은 접힌 줄의 본문을 버린다
const state = conversationDetails.value.get(id)
if (state) setDetail(id, state)
}
/** 그 줄을 뺀다 — 그사이 지워진 메일([더 보기]로 더 받은 대화라 다시 받지 않을 때). 아는 통 수도 하나 줄인다 */
function dropItem(id: string) {
if (!conversationItems.value.some((item) => item.id === id)) return
const info = conversationPageInfo.value
show({
items: conversationItems.value.filter((item) => item.id !== id),
pageInfo: info ? { ...info, total: info.total === null ? null : Math.max(0, info.total - 1) } : { hasNext: false, nextCursor: null, total: null },
})
}
/**
* 그 메일의 같은 대화를 받는다 (읽기 화면에 메일을 열 때 — useConversationSync).
* 같은 메일을 이미 받았거나 받는 중이면 그대로 둔다(다시 받기는 refreshConversation). 같은 대화의 다른 메일이면 보이던 줄을 둔 채 새로 받는다
*/
async function loadConversation(messageId: string): Promise<void> {
const status = conversationStatus.value
if (conversationMessageId.value === messageId && (status === 'ready' || status === 'loading')) return
const keep = status === 'ready' && conversationItems.value.some((item) => item.id === messageId)
const previousId = conversationMessageId.value
conversationMessageId.value = messageId
runSeq += 1
extended = false
conversationLoadingMore.value = false
if (keep) {
// 앞 메일은 읽기 화면이 열었다 — 읽음으로 바꿨다. 이제 지금 메일인 줄은 펼침을 접는다.
// 옛 커서는 옛 메일의 것이라 새로 받을 때까지 [더 보기]를 내린다(남은 쪽이 있다는 것은 그대로 — 통 수를 짐작하지 않게)
if (previousId) markedRead.add(previousId)
collapse(messageId)
const info = conversationPageInfo.value
if (info) conversationPageInfo.value = { ...info, nextCursor: null }
} else {
conversationSeq += 1
conversationItems.value = []
conversationPageInfo.value = null
conversationStatus.value = 'loading'
conversationError.value = null
conversationExpandedIds.value = new Set()
conversationDetails.value = new Map()
markedRead.clear()
}
const live = liveCheck(messageId)
// 처음 받을 때는 쪽마다 보인다 — 두 통 넘게 모였거나 · 다른 메일이 있다는 수(total)를 받았거나 · 더 묻지 않으면 칸을 그리고 뒤에서 잇는다
// (지금 메일 한 통만 받고 이어 묻는 동안은 그리지 않는다 — 남은 쪽이 모두 휴지통의 메일이면 칸이 없다)
const onPage = keep
? undefined
: (gathered: Gathered, continuing: boolean) => {
show(gathered)
conversationLoadingMore.value = continuing
const count = conversationCountOf(gathered.items, gathered.pageInfo)
if (!continuing || gathered.items.length > 1 || (count !== null && count > 1)) conversationStatus.value = 'ready'
}
try {
const gathered = await gather(messageId, null, live, { silent: true }, onPage)
if (!gathered) return
show(gathered)
conversationStatus.value = 'ready'
conversationError.value = null
conversationLoadingMore.value = false
} catch (error) {
if (!live()) return
conversationLoadingMore.value = false
// 받은 쪽이 있으면(이어 받다 실패 · 같은 대화의 줄을 둔 채) 받은 데까지 둔다
if (keep || conversationItems.value.length > 0) {
conversationStatus.value = 'ready'
return
}
conversationStatus.value = 'error'
conversationError.value = toApiError(error)
}
}
/** [다시 시도] — 받지 못한 대화를 처음부터 */
function retryConversation(): Promise<void> {
const messageId = conversationMessageId.value
if (!messageId || conversationStatus.value !== 'error') return Promise.resolve()
return loadConversation(messageId)
}
async function refreshOnce(): Promise<void> {
const messageId = conversationMessageId.value
if (!messageId || conversationStatus.value !== 'ready' || extended || conversationLoadingMore.value) return
runSeq += 1
const live = liveCheck(messageId)
try {
const gathered = await gather(messageId, null, live, { silent: true })
if (gathered) show(gathered)
} catch {
// 알리지 않는다 — 다음 소식에서 다시 받는다
}
}
/**
* 보이는 채로 다시 받는다 (메일함들의 통 수가 바뀜 · 쓰기 화면에서 돌아옴 — 때는 useConversationSync 가 모은다).
* 받은 것이 없거나 · 받는 중이거나 · [더 보기]로 더 받았으면 묻지 않는다. 한 번에 하나 — 그사이 온 요청은 끝난 뒤 한 번 더
*/
function refreshConversation(): Promise<void> {
if (refreshing) {
refreshAgain = true
return refreshing
}
const run = (async () => {
do {
refreshAgain = false
await refreshOnce()
} while (refreshAgain)
})().finally(() => {
if (refreshing === run) refreshing = null
})
refreshing = run
return run
}
/** 붙잡힌 메일 하나를 그 메일로 고친다 — 다 보냈으면 표시가 내려가고, 그사이 지워졌으면(404) 줄을 뺀다. 실패는 알리지 않는다 */
async function refreshHeldItem(id: string): Promise<void> {
const conversation = conversationSeq
const session = sessionGeneration()
try {
const detail = await tracked(() => mailApi.getMessage(id, { silent: true }))
if (conversation !== conversationSeq || session !== sessionGeneration()) return
const fresh = summaryOf(detail)
conversationItems.value = conversationItems.value.map((item) => (item.id === id ? fresh : item))
} catch (error) {
if (conversation !== conversationSeq || session !== sessionGeneration()) return
if (toApiError(error).code === 'RES_001') dropItem(id)
}
}
/**
* 붙잡힌 메일(보내기 취소 · 예약)이 있을 때만 다시 본다 — 엔진이 풀어도 바뀜 소식이 오지 않는다 (usePendingSendRefresh).
* [더 보기]로 더 받은 대화는 처음 쪽부터 다시 받지 않고 그 메일만 고친다
*/
async function refreshHeldConversation(): Promise<void> {
const held = conversationItems.value.filter((item) => isHeldMessage(item))
if (held.length === 0 || conversationStatus.value !== 'ready') return
if (!extended) {
await refreshConversation()
return
}
await Promise.all(held.map((item) => refreshHeldItem(item.id)))
}
/** 다시 받아 달라는 소식 — 모아 받는 것은 칸(useConversationSync)이 한다 */
function requestConversationRefresh() {
conversationRefreshSeq.value += 1
}
/** [더 보기] — 다음 쪽부터 한 쪽이 찰 때까지 이어 받는다. 실패는 인터셉터가 알리고 받은 데까지 둔다 */
async function loadMoreConversation(): Promise<void> {
const messageId = conversationMessageId.value
const pageInfo = conversationPageInfo.value
if (!messageId || !pageInfo || conversationStatus.value !== 'ready' || conversationLoadingMore.value) return
if (nextConversationCursor(pageInfo) === null) return
runSeq += 1
const live = liveCheck(messageId)
conversationLoadingMore.value = true
try {
await gather(messageId, { items: conversationItems.value, pageInfo }, live, {}, (gathered) => {
// 처음 쪽보다 더 받았다 — 이제 저절로 다시 받지 않는다
extended = true
show(gathered)
})
} catch {
// 인터셉터가 알렸다 — [더 보기]가 남는다
} finally {
if (live()) conversationLoadingMore.value = false
}
}
// ── 펼친 줄 ─────────────────────────────────────────
function setItemRead(id: string, isRead: boolean) {
conversationItems.value = conversationItems.value.map((item) => (item.id === id ? { ...item, isRead } : item))
}
/** 이 탭이 아는 읽음 — 열린 메일 · 보이는 목록 · 이 칸이 읽음으로 바꾼 것, 모르면 받아 둔 대화의 값 */
function knownRead(id: string, fallback: boolean): boolean {
const current = opened.selected.value
if (current?.id === id) return current.isRead
const listed = list.messages.value.find((message) => message.id === id)
if (listed) return listed.isRead
return markedRead.has(id) || fallback
}
/** 펼쳐 본 메일을 읽음으로 — 안 읽은 메일일 때만, 실패하면(markRead 가 목록 · 개수를 되돌렸다) 이 칸의 표시도 되돌린다 */
async function readExpanded(id: string, detail: MessageDetail): Promise<void> {
const item = conversationItems.value.find((entry) => entry.id === id)
if (!item) return
if (detail.isRead || knownRead(id, item.isRead)) {
if (!item.isRead) setItemRead(id, true)
return
}
const conversation = conversationSeq
markedRead.add(id)
setItemRead(id, true)
const done = await markRead({ id, isRead: false, mailboxIds: detail.mailboxIds })
if (done || conversation !== conversationSeq) return
markedRead.delete(id)
setItemRead(id, false)
}
/** 그사이 지워진 메일 — 대화를 다시 받아 그 줄을 뺀다. [더 보기]로 더 받은 대화는 다시 받지 않으니 그 줄만 뺀다 */
function forgetMissing(id: string) {
if (extended) dropItem(id)
else void refreshConversation()
}
/**
* 펼친 줄의 본문을 받는다 — 검색 쿼리 없이(강조 없음), 알리지 않고 줄 안에 그린다. 받았을 때 아직 펼쳐 있으면 읽음으로.
* 그사이 대화 · 세션이 바뀌었으면 버린다(읽음으로도 바꾸지 않는다). 그사이 지워진 메일(RES_001)이면 그 줄을 뺀다
*/
async function loadConversationDetail(id: string): Promise<void> {
const conversation = conversationSeq
const session = sessionGeneration()
const stale = () => conversation !== conversationSeq || session !== sessionGeneration()
setDetail(id, { status: 'loading' })
try {
const detail = await mailApi.getMessage(id, { silent: true })
if (stale()) return
setDetail(id, { status: 'ready', detail })
if (conversationExpandedIds.value.has(id)) void readExpanded(id, detail)
} catch (error) {
if (stale()) return
const apiError = toApiError(error)
setDetail(id, { status: 'error', error: apiError })
if (apiError.code === 'RES_001') forgetMissing(id)
}
}
/** 줄을 펴고 접는다 — 지금 메일의 줄은 펴지 않는다. 처음 펴면 본문을 받고, 받아 둔 본문이면 바로 읽음으로 */
function toggleConversationMessage(id: string): void {
if (id === conversationMessageId.value || !conversationItems.value.some((item) => item.id === id)) return
if (conversationExpandedIds.value.has(id)) {
collapse(id)
return
}
conversationExpandedIds.value = new Set([...conversationExpandedIds.value, id])
const state = conversationDetails.value.get(id)
if (state?.status === 'ready') {
setDetail(id, state)
void readExpanded(id, state.detail)
} else if (state?.status !== 'loading') {
void loadConversationDetail(id)
}
}
/** 펼친 줄의 본문 [다시 시도] */
function retryConversationDetail(id: string): Promise<void> {
return conversationDetails.value.get(id)?.status === 'error' ? loadConversationDetail(id) : Promise.resolve()
}
function reset() {
runSeq += 1
conversationSeq += 1
conversationMessageId.value = null
conversationItems.value = []
conversationPageInfo.value = null
conversationStatus.value = 'idle'
conversationError.value = null
conversationLoadingMore.value = false
conversationExpandedIds.value = new Set()
conversationDetails.value = new Map()
extended = false
refreshing = null
refreshAgain = false
markedRead.clear()
}
return {
conversationMessageId,
conversationItems,
conversationPageInfo,
conversationStatus,
conversationError,
conversationLoadingMore,
conversationRefreshSeq,
conversationExpandedIds,
conversationDetails,
conversationCount,
hasMoreConversation,
conversationVisible,
conversationFailed,
conversationBusy,
loadConversation,
retryConversation,
refreshConversation,
refreshHeldConversation,
requestConversationRefresh,
loadMoreConversation,
toggleConversationMessage,
retryConversationDetail,
reset,
}
}
export type ConversationState = ReturnType<typeof createConversationState>
+6
View File
@@ -180,6 +180,11 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
bumpLocalChange()
}
/** 그 메일에 보내는 중인 읽음 · 중요 표시 — 없으면 undefined (같은 표시를 두 번 보내지 않게 — message.ts 의 markRead) */
function pendingFlagsOf(id: string): FlagChanges | undefined {
return pendingFlags.get(id)
}
/** 보내는 중인 읽음 · 중요 표시를 덮어쓴다 */
function withPendingFlags<T extends MessageSummary>(items: readonly T[]): T[] {
if (pendingFlags.size === 0) return [...items]
@@ -525,6 +530,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
relocate,
beginPending,
endPending,
pendingFlagsOf,
neighborAfterRemoval,
openList,
openMailbox,
+9 -3
View File
@@ -74,9 +74,13 @@ interface MessageActionsDeps {
}
export function createMessageActions({ mailApi, mailboxes, list, opened }: MessageActionsDeps) {
/** 읽음 처리 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
async function markRead(message: Pick<MessageDetail, 'id' | 'isRead' | 'mailboxIds'>) {
if (message.isRead) return
/**
* 읽음 처리 — 화면을 먼저 바꾸고, 실패하면 되돌린다. 읽음이 됐으면(이미 읽음 · 읽음으로 보내는 중 포함) true, 되돌렸으면 false
* (같은 대화 칸의 펼친 줄이 자기 표시를 되돌릴지 본다 — conversation.ts).
* 같은 메일을 읽음으로 보내는 중이면(여러 통 처리 · 앞선 읽음 처리) 다시 보내지 않는다 — 안 읽은 수를 두 번 빼지 않게
*/
async function markRead(message: Pick<MessageDetail, 'id' | 'isRead' | 'mailboxIds'>): Promise<boolean> {
if (message.isRead || list.pendingFlagsOf(message.id)?.isRead === true) return true
// 주소로 바로 들어오면 메일함 목록보다 본문이 먼저 올 수 있다 — 그러면 아래 개수 조정이
// 늦게 온 메일함 응답에 덮어쓰이므로, 읽음 처리가 끝난 뒤 서버 기준으로 다시 맞춘다
const resync = mailboxes.mailboxCountsMayBeOverwritten()
@@ -89,10 +93,12 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
list.endPending([message.id], changes)
list.applySummary(summary)
if (resync || mailboxes.mailboxCountsMayBeOverwritten()) void mailboxes.loadMailboxes({ background: true })
return true
} catch {
list.endPending([message.id], changes)
list.setLocalFlags(message.id, { isRead: false })
mailboxes.adjustUnread(message.mailboxIds, 1)
return false
}
}
+2 -1
View File
@@ -189,7 +189,8 @@
* 14단계(검색 · 조건 검색) 밀도 예외 — 새 토큰 없이 위의 것을 다시 쓴다 (디자인 README "14단계 — 검색 · 대화 묶기", 사용자 확정 캔버스 Search14).
* 검색 칸 안의 [조건] 높이 32(--size-compact-control) ·
* 「조건으로 찾기」 패널의 칸(보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 날짜) · [조건 지우기] · [찾기] 높이 40(--size-filter-control) ·
* 결과 머리 아래 조건 줄 높이 40(--size-row-min) · 줄 안의 [조건 지우기] 36(--size-notice-action)
* 결과 머리 아래 조건 줄 높이 40(--size-row-min) · 줄 안의 [조건 지우기] 36(--size-notice-action) ·
* 읽기 화면 "같은 대화" 칸에서 펼친 줄의 [답장] · [이 메일만 열기] 36(--size-notice-action — 캔버스 Thread14)
*/
--size-status-dot: 0.5rem; /* 8px — 설정 › 알림의 실시간 연결 점 */
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */