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>