feat: 메일 14단계 — 자체 검색 색인 · 조건 검색 · 강조 · 대화 묶기 #18

Merged
ttipo merged 21 commits from feat/mail-search-index into main 2026-11-08 13:30:13 +09:00
46 changed files with 3782 additions and 183 deletions
Showing only changes of commit 726f9f854f - Show all commits
@@ -0,0 +1,333 @@
<script setup lang="ts">
// 분할 보기의 대화 줄 (14단계 — 디자인 원본 ListThreads14) — 2줄: 참여자 · 통 수(회색 "4통" — 한 통이면 없다) · 마지막 시각 / 마지막 메일 제목(+ 첨부)
// - 하나라도 안 읽었으면 굵게(뱃지 없이 — 메일 줄과 같다), 중요 표시한 메일이 있으면 별이 찬다. 누르면 그 메일함의 마지막 메일을 연다(아래에 같은 대화 칸)
// - 고르기 칸 · Ctrl(⌘) / Shift + 클릭 · 별은 메일 줄(MessageListItem)과 같은 자리 · 크기다 — 하는 일은 그 줄의 메일 모두(붙잡힌 메일은 빼고 — 스토어)
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title — 메일 줄의 고르기 칸과 같은 결)
// - 마지막 메일이 붙잡힌 메일이면 시각 자리가 "보내는 중" · "예약 · …"(청록 굵게 — 메일 목록과 같다)
// - 임시보관함의 제목 없는 저장본은 메일 줄처럼 흐린 "(제목 없음)" (리뷰 L5)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { ConversationSummary } from '@/shared/types/mail'
import { formatMailListTime } from '@/shared/utils/format'
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
interface Props {
row: ConversationSummary
/** 이름 자리 — 참여자 (useConversationRows) */
who: RowWho
/** 누르면 갈 곳 — 그 메일함의 마지막 메일(임시보관함은 쓰기 화면) */
to: RouteLocationRaw
/** 하나라도 안 읽었다 (임시보관함은 늘 false) */
unread?: boolean
/** 임시보관함의 줄 — 제목이 없으면 흐린 글자 */
draft?: boolean
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
held?: boolean
/** 지금 열려 있는 메일이 든 줄 */
selected?: boolean
/** 고른 줄 (여러 통 처리) */
checked?: boolean
/** 하나라도 골랐다 — 모든 줄에 고르기 상자를 보인다 */
checking?: boolean
}
interface Emits {
(e: 'toggle-flag', rowId: string): void
/** 고르기 · 풀기 — range 면 Shift(범위) */
(e: 'check', rowId: string, range: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
unread: false,
draft: false,
held: false,
selected: false,
checked: false,
checking: false,
})
const emit = defineEmits<Emits>()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
const time = computed(() => pendingLabel.value ?? formatMailListTime(props.row.latest.receivedAt))
/** Ctrl(⌘) · Shift + 클릭은 열지 않고 고른다 (메일 줄과 같다). 고를 수 없는 줄은 고르지 않는다 */
function onMainClick(event: MouseEvent) {
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) return
event.preventDefault()
if (!props.held) emit('check', props.row.id, event.shiftKey)
}
/** Shift + 누름이 글자를 고르지 않게 한다 */
function onMouseDown(event: MouseEvent) {
if (event.shiftKey) event.preventDefault()
}
function onStar() {
if (!props.held) emit('toggle-flag', props.row.id)
}
</script>
<template>
<li
:class="[
'thread-row',
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
]"
:data-row-id="row.id"
@mousedown="onMouseDown"
>
<MailCheckbox
class="thread-row__check"
:checked="checked"
:label="MAIL_BULK_TEXT.selectOne(subject)"
:unavailable="held"
:note="SEND_LATER_TEXT.heldNotSelectable"
@toggle="(event: MouseEvent) => emit('check', row.id, event.shiftKey)"
/>
<RouterLink
class="thread-row__main"
:to="to"
:data-message-id="row.latest.id"
@click="onMainClick"
>
<span class="thread-row__top">
<span :class="['thread-row__who', { 'is-placeholder': who.placeholder }]">
<span
v-if="unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ who.text }}
</span>
<span
v-if="countText"
class="thread-row__count"
data-thread-count
>{{ countText }}</span>
<time
:class="['thread-row__time', { 'is-pending': pendingLabel }]"
:datetime="row.latest.pendingSend?.sendAt ?? row.latest.receivedAt"
>{{ time }}</time>
</span>
<span class="thread-row__bottom">
<span :class="['thread-row__subject', { 'is-placeholder': draft && !row.latest.subject }]">{{ subject }}</span>
<AppIcon
v-if="row.hasAttachment"
name="paperclip"
:size="13"
:stroke-width="2"
:label="MAIL_LIST_ROW_TEXT.attachment"
class="thread-row__clip"
/>
</span>
</RouterLink>
<button
type="button"
:class="['thread-row__star', { 'is-flagged': row.isFlagged }]"
:aria-label="MAIL_LIST_ROW_TEXT.flag"
:aria-pressed="row.isFlagged"
:aria-disabled="held ? 'true' : undefined"
:title="held ? MAIL_CONVERSATION_LIST_TEXT.heldFlagNote : undefined"
data-thread-star
@click="onStar"
>
<AppIcon
name="star"
:size="15"
:filled="row.isFlagged"
/>
</button>
</li>
</template>
<style scoped>
/* 메일 줄(MessageListItem)과 같은 자리 · 크기 — 2줄 약 50px (디자인 원본 ListThreads14 · Main) */
.thread-row {
position: relative;
display: flex;
align-items: stretch;
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface);
}
.thread-row:hover {
background: var(--color-surface-panel);
}
.thread-row.is-selected {
background: var(--color-brand-subtle);
}
.thread-row.is-checked {
background: var(--color-row-checked);
}
.thread-row.is-checked:hover {
background: var(--color-row-checked-hover);
}
/* 고르기 칸 — 숨긴 동안에도 화면 밖에 두어 Tab 으로 닿게 한다 (메일 줄과 같다) */
.thread-row__check {
position: absolute;
width: var(--space-1);
height: var(--space-1);
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* 마우스를 올리거나 초점이 가면 · 하나라도 고르면 — 왼쪽 36px(밀도 예외 --size-row-check)에 16px 상자 */
.thread-row:hover .thread-row__check,
.thread-row:focus-within .thread-row__check,
.thread-row.is-checking .thread-row__check {
position: static;
flex: 0 0 auto;
justify-content: flex-end;
width: var(--size-row-check);
height: auto;
overflow: visible;
clip-path: none;
}
.thread-row__check:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
.thread-row__main {
flex: 1 1 auto;
min-width: 0;
min-height: var(--size-list-row);
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--space-1);
padding: var(--space-6) var(--space-2) var(--space-6) var(--space-16);
line-height: var(--line-height-heading);
color: inherit;
text-decoration: none;
}
.thread-row:hover .thread-row__main,
.thread-row:focus-within .thread-row__main,
.thread-row.is-checking .thread-row__main {
padding-left: var(--space-10);
}
.thread-row__main:hover {
color: inherit;
}
.thread-row__main:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
/* 첫 줄 — 참여자(넘치면 줄인다) · 통 수 · 시각(오른쪽 끝) (디자인 원본 ListThreads14) */
.thread-row__top {
display: flex;
align-items: center;
gap: var(--space-6);
}
.thread-row__who {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 450;
color: var(--color-text-strong);
}
/* 통 수 — 회색 글, 뱃지 · 알약 없음 */
.thread-row__count {
flex: 0 0 auto;
font-size: var(--font-size-small);
font-weight: 500;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.thread-row__time {
flex: 0 0 auto;
margin-left: auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.thread-row__time.is-pending,
.thread-row.is-unread .thread-row__time.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
.thread-row__bottom {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-6);
}
.thread-row__subject {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
}
.thread-row__clip {
flex: 0 0 auto;
color: var(--color-text-muted);
}
/* 비어 있는 값 — "(받는 사람 없음)" · 임시 저장본의 "(제목 없음)" (메일 줄과 같다) */
.thread-row__who.is-placeholder,
.thread-row__subject.is-placeholder {
color: var(--color-text-muted);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 (메일 줄과 같다) */
.thread-row.is-unread .thread-row__who {
font-weight: 650;
color: var(--color-text);
}
.thread-row.is-unread .thread-row__subject,
.thread-row.is-unread .thread-row__time {
font-weight: 600;
color: var(--color-text);
}
.thread-row__star {
flex: 0 0 auto;
width: var(--size-touch);
display: flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: var(--color-icon-muted);
}
.thread-row__star.is-flagged {
color: var(--color-star);
}
.thread-row__star[aria-disabled='true'] {
cursor: not-allowed;
}
.thread-row__star:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
</style>
@@ -0,0 +1,287 @@
<script setup lang="ts">
// 목록 보기 표의 대화 줄(38) — 고르기 · 별 · 참여자(+ 통 수) · 제목(링크 + 첨부 클립) · 날짜 (14단계 — 보드 ListThreads14 에 표가 없어 정했다)
// - 칸은 메일 표(MessageTableRow)와 같은 자리 · 폭이다. 크기 칸은 없다 — 대화 하나의 크기는 마지막 메일의 크기가 아니다
// - 참여자 뒤에 회색 "4통"(한 통이면 없다) — 참여자가 길면 이름이 먼저 줄고 통 수는 남는다
// - 행 어디를 눌러도 그 메일함의 마지막 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. Ctrl(⌘) · Shift + 클릭은 고른다 (메일 표와 같다)
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { ConversationSummary } from '@/shared/types/mail'
import { formatMailTableDate } from '@/shared/utils/format'
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
interface Props {
row: ConversationSummary
who: RowWho
to: RouteLocationRaw
unread?: boolean
/** 임시보관함의 줄 — 제목이 없으면 흐린 글자 */
draft?: boolean
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
held?: boolean
checked?: boolean
}
interface Emits {
(e: 'open', location: RouteLocationRaw): void
(e: 'toggle-flag', rowId: string): void
/** 고르기 · 풀기 — range 면 Shift(범위) */
(e: 'check', rowId: string, range: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
unread: false,
draft: false,
held: false,
checked: false,
})
const emit = defineEmits<Emits>()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
/** 행 어디를 눌러도 연다 — 버튼 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다) */
function onRowClick(event: MouseEvent) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('button')) return
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
if (!props.held) emit('check', props.row.id, event.shiftKey)
return
}
if (target?.closest('a')) return
if (window.getSelection()?.toString()) return
emit('open', props.to)
}
/** Shift + 누름이 글자를 고르지 않게 한다 */
function onMouseDown(event: MouseEvent) {
if (event.shiftKey) event.preventDefault()
}
function onStar() {
if (!props.held) emit('toggle-flag', props.row.id)
}
</script>
<template>
<tr
:class="['thread-table-row', { 'is-unread': unread, 'is-checked': checked }]"
:data-row-id="row.id"
@click="onRowClick"
@mousedown="onMouseDown"
>
<td class="mail-table__check">
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (메일 표와 같다) -->
<MailCheckbox
class="thread-table-row__check"
:checked="checked"
:label="MAIL_BULK_TEXT.selectOne(subject)"
:unavailable="held"
:note="SEND_LATER_TEXT.heldNotSelectable"
@toggle="(event: MouseEvent) => emit('check', row.id, event.shiftKey)"
/>
</td>
<td class="mail-table__star">
<button
type="button"
:class="['thread-table-row__star', { 'is-flagged': row.isFlagged }]"
:aria-label="MAIL_LIST_ROW_TEXT.flag"
:aria-pressed="row.isFlagged"
:aria-disabled="held ? 'true' : undefined"
:title="held ? MAIL_CONVERSATION_LIST_TEXT.heldFlagNote : undefined"
data-thread-star
@click="onStar"
>
<AppIcon
name="star"
:size="14"
:filled="row.isFlagged"
/>
</button>
</td>
<td class="thread-table-row__who-cell">
<div class="thread-table-row__who-line">
<span :class="['thread-table-row__who', { 'is-placeholder': who.placeholder }]">
<span
v-if="unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ who.text }}
</span>
<span
v-if="countText"
class="thread-table-row__count"
data-thread-count
>{{ countText }}</span>
</div>
</td>
<td class="mail-table__subject">
<div class="thread-table-row__subject-line">
<RouterLink
:class="['thread-table-row__link', { 'is-placeholder': draft && !row.latest.subject }]"
:to="to"
:data-message-id="row.latest.id"
>
{{ subject }}
</RouterLink>
<AppIcon
v-if="row.hasAttachment"
name="paperclip"
:size="13"
:stroke-width="2"
:label="MAIL_LIST_ROW_TEXT.attachment"
class="thread-table-row__clip"
/>
</div>
</td>
<td
:class="['mail-table__date', { 'is-pending': pendingLabel }]"
:title="pendingLabel ?? undefined"
>
<time :datetime="row.latest.pendingSend?.sendAt ?? row.latest.receivedAt">{{
pendingLabel ?? formatMailTableDate(row.latest.receivedAt)
}}</time>
</td>
</tr>
</template>
<style scoped>
/* 메일 표의 행(MessageTableRow)과 같은 높이 · 바탕 (디자인 원본 MainList · BulkSelect) */
.thread-table-row {
height: var(--size-table-row);
cursor: pointer;
}
.thread-table-row td {
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.thread-table-row:hover {
background: var(--color-row-hover);
}
.thread-table-row:focus-within {
background: var(--color-brand-subtle);
}
.thread-table-row.is-checked {
background: var(--color-row-checked);
}
.thread-table-row.is-checked:hover {
background: var(--color-row-checked-hover);
}
.thread-table-row__check {
width: var(--table-col-check);
height: calc(var(--size-table-row) - var(--border-width));
justify-content: flex-start;
}
.thread-table-row__star {
width: var(--size-compact-control);
height: calc(var(--size-table-row) - var(--border-width));
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0;
border: 0;
border-radius: var(--radius-xs);
background: transparent;
color: var(--color-icon-muted);
}
.thread-table-row__star.is-flagged {
color: var(--color-star);
}
.thread-table-row__star[aria-disabled='true'] {
cursor: not-allowed;
}
/* 참여자 칸 — 이름이 먼저 줄고 통 수는 남는다 */
.thread-table-row__who-line {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-6);
}
.thread-table-row__who {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 450;
color: var(--color-text-strong);
}
.thread-table-row__count {
flex: 0 0 auto;
font-size: var(--font-size-small);
font-weight: 500;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.thread-table-row__subject-line {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-6);
}
.thread-table-row__link {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
text-decoration: none;
}
.thread-table-row__link:hover {
color: var(--color-text);
text-decoration: underline;
}
.thread-table-row__clip {
flex: 0 0 auto;
color: var(--color-text-muted);
}
/* 비어 있는 값 — "(받는 사람 없음)" · 임시 저장본의 "(제목 없음)" (메일 표와 같다) */
.thread-table-row__who.is-placeholder,
.thread-table-row__link.is-placeholder {
color: var(--color-text-muted);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.thread-table-row.is-unread .thread-table-row__who,
.thread-table-row.is-unread .thread-table-row__link {
font-weight: 650;
color: var(--color-text);
}
.thread-table-row.is-unread .mail-table__date {
font-weight: 600;
color: var(--color-text);
}
.thread-table-row .mail-table__date.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
</style>
+17 -13
View File
@@ -2,6 +2,8 @@
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
// 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다.
// 대화로 묶은 메일함(14단계)은 줄(대화) 사이를 옮긴다 — 이전/다음은 앞뒤 줄의 마지막 메일, 위치는 "n번째 대화 / 대화 수".
// 연 메일이 줄의 마지막 메일이 아니어도([이 메일만 열기]로 연 더 오래된 메일) 그 메일이 든 줄을 기준으로 한다
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -32,16 +34,18 @@ const listHref = computed(() => {
const source = mailStore.listSource
return source ? router.resolve(listLocationOf(source)).href : router.resolve({ name: 'mail' }).href
})
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
/** 불러온 목록에서의 위치(줄) — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
const index = computed(() => mailStore.rowIndexOfMessage(props.messageId))
/**
* 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수.
* 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다
* 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다.
* 대화 목록은 대화 수 — 모르면 다 받았을 때만 불러온 줄 수 (메일함의 통 수는 대화 수가 아니다)
*/
const total = computed<number | null>(() => {
const known = mailStore.pageInfo?.total ?? null
if (known !== null) return known
if (mailStore.search) return null
if (mailStore.isConversationList) return mailStore.hasMore ? null : mailStore.rowCount
return mailStore.currentMailbox?.totalCount ?? mailStore.messages.length
})
const position = computed(() =>
@@ -51,18 +55,17 @@ const hasPrev = computed(() => index.value > 0)
/** 불러온 범위의 끝이어도 서버에 더 있거나(hasNext) 뒤에서 이어 받는 중이면 다음으로 갈 수 있다 */
const hasNext = computed(
() =>
index.value !== -1 &&
(index.value < mailStore.messages.length - 1 || mailStore.hasMore || mailStore.isLoadingMore),
index.value !== -1 && (index.value < mailStore.rowCount - 1 || mailStore.hasMore || mailStore.isLoadingMore),
)
/** 다음 메일을 받아야 하는데 목록을 보이는 채로 새로 고치는 중 — 끝날 때까지 [다음]을 막는다 (옛 커서로 붙이지 않게) */
const nextWaiting = computed(
() => index.value !== -1 && !mailStore.messages[index.value + 1] && mailStore.isRefreshing,
() => index.value !== -1 && mailStore.rowMessageIdAt(index.value + 1) === null && mailStore.isRefreshing,
)
async function goPrev() {
const previous = mailStore.messages[index.value - 1]
const previous = mailStore.rowMessageIdAt(index.value - 1)
const source = mailStore.listSource
if (previous && source) await showSiblingIn(source, previous.id)
if (previous && source) await showSiblingIn(source, previous)
}
/**
@@ -72,19 +75,19 @@ async function goPrev() {
async function goNext() {
if (loadingNext.value || nextWaiting.value || index.value === -1) return
const current = index.value
if (!mailStore.messages[current + 1] && (mailStore.hasMore || mailStore.isLoadingMore)) {
if (mailStore.rowMessageIdAt(current + 1) === null && (mailStore.hasMore || mailStore.isLoadingMore)) {
loadingNext.value = true
try {
const waited = mailStore.isLoadingMore
await mailStore.loadMore()
if (waited && !mailStore.messages[current + 1] && mailStore.hasMore) await mailStore.loadMore()
if (waited && mailStore.rowMessageIdAt(current + 1) === null && mailStore.hasMore) await mailStore.loadMore()
} finally {
loadingNext.value = false
}
}
const next = mailStore.messages[current + 1]
const next = mailStore.rowMessageIdAt(current + 1)
const source = mailStore.listSource
if (next && source) await showSiblingIn(source, next.id)
if (next && source) await showSiblingIn(source, next)
}
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
@@ -94,7 +97,8 @@ function onBackToList(event: MouseEvent) {
event.preventDefault()
const source = mailStore.listSource
if (!source) return
viewStore.setReturnFocus(props.messageId)
// 대화 목록은 줄의 링크가 마지막 메일을 가리킨다 — 그 줄로 초점을 돌려준다
viewStore.setReturnFocus(mailStore.rowMessageIdAt(index.value) ?? props.messageId)
void backToSourceList(source)
}
</script>
@@ -0,0 +1,80 @@
<script setup lang="ts">
// 목록 머리 오른쪽의 [대화로 묶기] (14단계 — 디자인 원본 ListThreads14): 눌림 단추(aria-pressed) · 높이 36(--size-notice-action — 안내 줄 단추의
// 밀도 예외를 새 토큰 없이 다시 쓴다). 누르면 설정(groupByConversation)을 PATCH 한다 — 화면을 먼저 바꾸고(메일함 화면이 바로 묶거나 푼다)
// 끝내 실패하면 서버가 확인한 값으로 되돌린다(mail-settings.store — 칸마다 저장은 한 번에 하나, 알림은 인터셉터).
// 분할 보기의 목록 칸(384)은 머리가 좁아 아이콘만(36×36 — 이름은 title · 화면 낭독, MailboxEmptyButton 과 같은 결), 목록 보기는 아이콘 + 이름.
// 켠 모양은 보기 전환의 고른 칸과 같다(청록 옅은 바탕 · 진한 청록 글자 — 테두리는 그대로, 보드의 #9FD3D7 테두리는 새 색이라 두지 않았다)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
interface Props {
variant: 'split' | 'list'
}
const props = defineProps<Props>()
const settingsStore = useMailSettingsStore()
const pressed = computed(() => settingsStore.groupByConversation)
const iconOnly = computed(() => props.variant === 'split')
function toggle() {
void settingsStore.update('groupByConversation', !pressed.value)
}
</script>
<template>
<button
type="button"
:class="['group-toggle', { 'is-on': pressed, 'group-toggle--icon': iconOnly }]"
:aria-pressed="pressed"
:title="iconOnly ? MAIL_CONVERSATION_LIST_TEXT.toggle : undefined"
data-group-toggle
@click="toggle"
>
<AppIcon
name="message-square"
:size="14"
:stroke-width="1.9"
/>
<span :class="{ 'visually-hidden': iconOnly }">{{ MAIL_CONVERSATION_LIST_TEXT.toggle }}</span>
</button>
</template>
<style scoped>
/* 높이 36(밀도 예외 --size-notice-action) · 테두리 · 12.5px 굵게 (디자인 원본 ListThreads14) */
.group-toggle {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
min-height: var(--size-notice-action);
padding: 0 var(--space-10);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-size: var(--font-size-label);
font-weight: 600;
white-space: nowrap;
}
.group-toggle:hover:not(.is-on) {
background: var(--color-surface-muted);
}
.group-toggle.is-on {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
/* 분할 보기 — 아이콘만, 정사각형 36 */
.group-toggle--icon {
width: var(--size-notice-action);
padding: 0;
}
</style>
@@ -8,6 +8,7 @@
// 결과 없음은 더 찾을 것이 없을 때만 — 이어 찾을 것이 남았는데 아직 하나도 못 찾았으면 [더 찾기](MailSearchMore)
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
// - 대화로 묶은 메일함(14단계)도 같은 틀이다 — 줄 수 · 빈 상태는 메일 목록과 같은 규칙(줄이 대화일 뿐)
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -75,7 +76,7 @@ const retentionNotice = computed(() => {
const role = mailbox.value?.role ?? null
const text = role ? (MAILBOX_RETENTION_NOTICES[role] ?? null) : null
if (text === null) return null
return mailStore.listStatus === 'ready' && mailStore.messages.length === 0 ? null : text
return mailStore.listStatus === 'ready' && mailStore.rowCount === 0 ? null : text
})
/** 검색 조건 — 조건 줄은 걸렸을 때만 (14단계) */
const searchConditions = computed(() =>
@@ -97,15 +98,17 @@ const singleChar = computed(
const loadingMore = computed(() => mailStore.isLoadingMore || mailStore.isRefreshing)
/** 결과가 아직 없지만 이어 찾을 것이 남았다 — 결과 없음 대신 [더 찾기] */
const searchPending = computed(
() => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.messages.length === 0 && mailStore.hasMore,
() => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.rowCount === 0 && mailStore.hasMore,
)
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
function retry() {
const current = search.value
const source = mailStore.listSource
if (current) void mailStore.openSearch(current.q, current.scope, { force: true, conditions: current.conditions })
else if (scheduled.value) void mailStore.openScheduled()
else if (source?.kind === 'conversations') void mailStore.openConversations(source.mailboxId)
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
}
@@ -240,7 +243,7 @@ watch(
@leave="leave"
/>
<template v-else-if="mailStore.messages.length > 0">
<template v-else-if="mailStore.rowCount > 0">
<slot />
<div
v-if="mailStore.hasMore"
+53 -12
View File
@@ -1,5 +1,9 @@
<script setup lang="ts">
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
// 목록 머리 — 메일함 이름 · 개수 · [대화로 묶기](14단계) · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
// 개수(14단계 — 디자인 원본 ListThreads14): 묶지 않았으면 "메일 9통, 안 읽은 메일 2통"(메일함의 통 수), 묶었으면 "대화 6개, 안 읽은 메일 2통"
// (대화 수는 목록의 total — 모르면 그 부분을 뺀다). 안 읽은 메일이 없으면 뒤를 뺀다.
// [대화로 묶기]는 메일함 목록(묶었든 아니든)에서만 — 검색 결과 · 예약됨은 묶지 않는다. 설정을 받는 동안은 숨기고(그사이 고른 것은 받기가 덮는다),
// 받았거나 받지 못했으면 보인다 — 받지 못했으면 짐작 위에서 고르고 그 칸만 저장한다 (mail-settings.store — 리뷰 L4)
// 예약됨(9단계)은 "예약됨" · "2통 · 보낼 시각 차례" (디자인 원본 MailSendLater)
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
@@ -7,12 +11,15 @@
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailConversationToggle from '@/components/mail/MailConversationToggle.vue'
import MailboxEmptyButton from '@/components/mail/MailboxEmptyButton.vue'
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import { formatCount } from '@/shared/utils/format'
import { isScheduledSource } from '@/shared/utils/mail-list-source'
import { listHeadCountText } from '@/shared/utils/mail-conversation-list'
import { isScheduledSource, mailboxListIdOf } from '@/shared/utils/mail-list-source'
import { isEmptiableMailbox } from '@/shared/utils/mailbox'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -26,6 +33,7 @@ withDefaults(defineProps<Props>(), {
})
const mailStore = useMailStore()
const settingsStore = useMailSettingsStore()
const scheduled = computed(() => isScheduledSource(mailStore.listSource))
const title = computed(() => (scheduled.value ? SEND_LATER_TEXT.mailbox : (mailStore.currentMailbox?.name ?? '메일함')))
@@ -36,9 +44,17 @@ const countText = computed(() => {
}
const box = mailStore.currentMailbox
if (!box) return ''
const total = `전체 ${formatCount(box.totalCount)}`
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
if (mailStore.isConversationList) {
return listHeadCountText({ grouped: true, total: mailStore.pageInfo?.total ?? null, unread: box.unreadCount })
}
return listHeadCountText({ grouped: false, total: box.totalCount, unread: box.unreadCount })
})
/** [대화로 묶기] — 메일함 목록에서만, 설정을 받았거나 받지 못했을 때 (14단계 — 받는 중 · 받기 전에는 숨긴다) */
const groupable = computed(
() =>
mailboxListIdOf(mailStore.listSource) !== null &&
(settingsStore.status === 'ready' || settingsStore.status === 'error'),
)
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함에 메일이 있을 때 */
const emptiable = computed(() => {
@@ -49,14 +65,24 @@ const emptiable = computed(() => {
<template>
<div :class="['list-header', `list-header--${variant}`]">
<h1
:id="titleId"
class="list-header__title"
>
{{ title }}
</h1>
<span class="list-header__count">{{ countText }}</span>
<div class="list-header__heading">
<h1
:id="titleId"
class="list-header__title"
>
{{ title }}
</h1>
<!-- 분할 보기 칸에서는 줄어 잘릴 수 있다 — 마우스를 올리면 다 보인다 (글은 그대로 있어 화면 낭독은 다 읽는다) -->
<span
class="list-header__count"
:title="countText || undefined"
>{{ countText }}</span>
</div>
<div class="list-header__end">
<MailConversationToggle
v-if="groupable"
:variant="variant"
/>
<MailboxEmptyButton
v-if="emptiable"
:mailbox="emptiable"
@@ -92,7 +118,22 @@ const emptiable = computed(() => {
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
}
/*
* 이름 · 개수 — 오른쪽 단추들이 남긴 자리를 쓴다. 좁으면 개수가 먼저 줄고(사라질 때까지) 그다음에 이름이 준다
* (14단계 — [대화로 묶기]가 더해져 분할 보기 칸에서 이름이 1px 모자라 "색인 …"으로 잘렸다)
*/
.list-header__heading {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-8);
overflow: hidden;
}
.list-header__title {
flex: 0 0 auto;
max-width: 100%;
min-width: 0;
margin: 0;
overflow: hidden;
@@ -105,7 +146,7 @@ const emptiable = computed(() => {
/* 좁으면 이름보다 먼저 줄어든다 */
.list-header__count {
flex: 0 100 auto;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
font-size: var(--font-size-small);
@@ -8,6 +8,7 @@
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다
import { computed, nextTick, ref, watch } from 'vue'
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
@@ -16,6 +17,7 @@ import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
import { useBulkMailActions } from '@/composables/useBulkMailActions'
import type { IconName } from '@/shared/constants/icons'
import { MAIL_BULK_TEXT, MAIL_SPAM_TEXT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { formatCount } from '@/shared/utils/format'
import { useMailStore } from '@/stores/mail.store'
@@ -70,7 +72,10 @@ const rovingTool = computed<ToolId>(() => {
/** 분할 보기의 목록 칸은 아이콘만, 목록 보기는 폭 1100 이상에서 이름까지 */
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
const countText = computed(() => {
const count = formatCount(mailStore.checkedCount)
return mailStore.isConversationList ? MAIL_CONVERSATION_LIST_TEXT.selected(count) : MAIL_BULK_TEXT.selected(count)
})
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
const spamLabel = computed(() => (bulk.inJunk.value ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report))
const leadingTools = computed<LeadingTool[]>(() => [
@@ -231,7 +236,7 @@ watch(disabledTools, (disabled) => {
</div>
<MailPurgeDialog
:open="purgeOpen"
:count="mailStore.checkedCount"
:count="mailStore.checkedActionIds.length"
id-prefix="bulk-purge"
@confirm="onPurgeConfirm"
@cancel="purgeOpen = false"
@@ -60,7 +60,7 @@ const title = computed(() => {
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 listedIds = computed<ReadonlySet<string>>(() => new Set(mailStore.listedSummaries.map((message) => message.id)))
const rows = computed(() =>
conversationRowsOf(mailStore.conversationItems, {
currentId: props.messageId,
+38 -15
View File
@@ -2,13 +2,17 @@
// 분할 보기의 목록 칸 — 2줄 행 (디자인 원본 Main 가운데 · Search 왼쪽 · ListStates "분할 보기에서 여러 통 고르기")
// 머리 · 상태(불러오는 중 · 못 불러옴 · 빈 메일함 · 검색 결과 없음) · 메일 더 보기는 목록 틀(MailListFrame)이 맡는다.
// 목록은 메일함 또는 검색 결과다 — 행 규칙은 useMailListRows.
// 대화로 묶은 메일함(14단계 — 디자인 원본 ListThreads14)은 한 줄이 대화 하나다 — 줄 규칙은 useConversationRows, 줄은 ConversationListItem
import ConversationListItem from '@/components/mail/ConversationListItem.vue'
import MailListFrame from '@/components/mail/MailListFrame.vue'
import MessageListItem from '@/components/mail/MessageListItem.vue'
import { useConversationRows } from '@/composables/useConversationRows'
import { useMailListRows } from '@/composables/useMailListRows'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
const rows = useMailListRows()
const conversationRows = useConversationRows()
/** Shift 면 범위, 아니면 하나 */
function onCheck(id: string, range: boolean) {
@@ -24,21 +28,40 @@ function onCheck(id: string, range: boolean) {
title-id="message-list-title"
>
<ul class="message-list__items">
<MessageListItem
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:to="rows.locationOf(message)"
:role="rows.roleOf(message)"
:mixed="rows.mixed.value"
:mailbox-name="rows.mailboxNameOf(message)"
:selected="message.id === mailStore.selectedId"
:checked="mailStore.checkedIds.has(message.id)"
:checking="mailStore.checkedCount > 0"
:search="Boolean(mailStore.search)"
@toggle-flag="mailStore.toggleFlag"
@check="onCheck"
/>
<template v-if="mailStore.isConversationList">
<ConversationListItem
v-for="row in mailStore.conversations"
:key="row.id"
:row="row"
:who="conversationRows.whoOf(row, 'split')"
:to="conversationRows.locationOf(row)"
:unread="conversationRows.isUnread(row)"
:draft="conversationRows.isDraft.value"
:held="conversationRows.isHeld(row)"
:selected="conversationRows.isSelected(row)"
:checked="mailStore.checkedIds.has(row.id)"
:checking="mailStore.checkedCount > 0"
@toggle-flag="conversationRows.toggleFlag"
@check="onCheck"
/>
</template>
<template v-else>
<MessageListItem
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:to="rows.locationOf(message)"
:role="rows.roleOf(message)"
:mixed="rows.mixed.value"
:mailbox-name="rows.mailboxNameOf(message)"
:selected="message.id === mailStore.selectedId"
:checked="mailStore.checkedIds.has(message.id)"
:checking="mailStore.checkedCount > 0"
:search="Boolean(mailStore.search)"
@toggle-flag="mailStore.toggleFlag"
@check="onCheck"
/>
</template>
</ul>
</MailListFrame>
</template>
@@ -215,7 +215,7 @@ async function onBlock(target: string, moveToJunk: boolean) {
const nextId = mailStore.neighborAfterRemoval(message.id)
const outcome = await senderBlock.block(message, target, moveToJunk)
if (!outcome.moved || outcome.stale) return
const removed = !mailStore.messages.some((item) => item.id === message.id)
const removed = !mailStore.isListedMessage(message.id)
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
}
@@ -93,10 +93,10 @@ describe('MessageTable (목록 보기)', () => {
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
})
it('머리에 "전체 N · 안 읽음 M" 과 보기 전환이 있다', async () => {
it('머리에 "메일 N통, 안 읽은 메일 M통"(14단계 — 디자인 원본 ListThreads14) 과 보기 전환이 있다', async () => {
const { wrapper } = await mountTable()
expect(wrapper.get('.list-header__count').text()).toBe('전체 15 · 안 읽음 2')
expect(wrapper.get('.list-header__count').text()).toBe('메일 15통, 안 읽은 메일 2통')
expect(wrapper.find('[role="group"][aria-label="보기 방식"]').exists()).toBe(true)
})
+61 -19
View File
@@ -3,23 +3,36 @@
// 칸: 고르기 32 · 별 32 · 보낸 사람 168 · [메일함 96 — 전체 메일함 검색 결과] · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12)
// — tokens.css 의 --table-* 값. 머리 줄은 MessageTableHead, 행은 MessageTableRow.
// 머리 · 상태 · 메일 더 보기 · 표 위치 기억(메일을 열었다 돌아올 때)은 목록 틀(MailListFrame)이 맡는다.
// 대화로 묶은 메일함(14단계 — 보드 ListThreads14 에 표가 없어 정했다)은 한 줄이 대화 하나다(ConversationTableRow):
// 고르기 · 별 · 보낸 사람(참여자 + 통 수) · 제목 · 날짜 — 크기 칸은 없다(대화의 크기는 마지막 메일의 크기가 아니다)
import { computed } from 'vue'
import { useRouter, type RouteLocationRaw } from 'vue-router'
import ConversationTableRow from '@/components/mail/ConversationTableRow.vue'
import MailListFrame from '@/components/mail/MailListFrame.vue'
import MessageTableHead from '@/components/mail/MessageTableHead.vue'
import MessageTableRow from '@/components/mail/MessageTableRow.vue'
import { useConversationRows } from '@/composables/useConversationRows'
import { useMailListRows } from '@/composables/useMailListRows'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { whoColumnLabel } from '@/shared/utils/mail-list-row'
import { useMailStore } from '@/stores/mail.store'
const router = useRouter()
const mailStore = useMailStore()
const rows = useMailListRows()
const conversationRows = useConversationRows()
const caption = computed(() =>
mailStore.search ? MAIL_SEARCH_TEXT.title : `${mailStore.currentMailbox?.name ?? '메일함'} 메일 목록`,
)
/** 대화로 묶은 메일함 — 크기 칸이 없고 보낸 사람 칸에 참여자를 보인다 */
const threads = computed(() => mailStore.isConversationList)
const caption = computed(() => {
if (mailStore.search) return MAIL_SEARCH_TEXT.title
const name = mailStore.currentMailbox?.name ?? '메일함'
return threads.value ? MAIL_CONVERSATION_LIST_TEXT.tableCaption(name) : `${name} 메일 목록`
})
/** 보낸 사람 칸 머리 — 대화는 참여자(보낸 사람들)라 어느 메일함이든 "보낸 사람"(받은편지함의 칸 이름과 같다) */
const whoHeader = computed(() => (threads.value ? whoColumnLabel(null, false) : rows.whoHeader.value))
/** Shift 면 범위, 아니면 하나 */
function onCheck(id: string, range: boolean) {
@@ -40,7 +53,7 @@ function open(location: RouteLocationRaw) {
>
<!-- 표 최소 폭(440px · 메일함 칸이 있으면 더 넓다)보다 좁으면 가로로 스크롤한다 -->
<div class="message-table__scroll">
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value }]">
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value, 'mail-table--threads': threads }]">
<caption class="visually-hidden">
{{ caption }}
</caption>
@@ -54,26 +67,48 @@ function open(location: RouteLocationRaw) {
>
<col class="mail-table__col-subject">
<col class="mail-table__col-date">
<col class="mail-table__col-size">
<col
v-if="!threads"
class="mail-table__col-size"
>
</colgroup>
<MessageTableHead
:who-header="rows.whoHeader.value"
:who-header="whoHeader"
:mixed="rows.mixed.value"
:threads="threads"
/>
<tbody>
<MessageTableRow
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:to="rows.locationOf(message)"
:role="rows.roleOf(message)"
:mixed="rows.mixed.value"
:mailbox-name="rows.mailboxNameOf(message)"
:checked="mailStore.checkedIds.has(message.id)"
@open="open"
@toggle-flag="mailStore.toggleFlag"
@check="onCheck"
/>
<template v-if="threads">
<ConversationTableRow
v-for="row in mailStore.conversations"
:key="row.id"
:row="row"
:who="conversationRows.whoOf(row, 'table')"
:to="conversationRows.locationOf(row)"
:unread="conversationRows.isUnread(row)"
:draft="conversationRows.isDraft.value"
:held="conversationRows.isHeld(row)"
:checked="mailStore.checkedIds.has(row.id)"
@open="open"
@toggle-flag="conversationRows.toggleFlag"
@check="onCheck"
/>
</template>
<template v-else>
<MessageTableRow
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:to="rows.locationOf(message)"
:role="rows.roleOf(message)"
:mixed="rows.mixed.value"
:mailbox-name="rows.mailboxNameOf(message)"
:checked="mailStore.checkedIds.has(message.id)"
@open="open"
@toggle-flag="mailStore.toggleFlag"
@check="onCheck"
/>
</template>
</tbody>
</table>
</div>
@@ -171,6 +206,13 @@ function open(location: RouteLocationRaw) {
font-variant-numeric: tabular-nums;
}
/* 대화로 묶은 메일함 — 크기 칸이 없어 날짜 칸이 마지막 칸이다 (14단계 — 1100px 미만은 아래 규칙이 같은 폭을 준다) */
@media (min-width: 68.75rem) {
.mail-table--threads .mail-table__col-date {
width: calc(var(--table-col-date) + var(--space-8));
}
}
/* 1100px 미만 — 크기 칸을 숨기고 날짜 칸이 마지막 칸이 된다 (tokens.css 화면 폭 기준) */
@media (max-width: 68.6875rem) {
.mail-table__col-size,
@@ -1,10 +1,12 @@
<script setup lang="ts">
// 목록 보기 표의 머리 줄(32) — 고르기 · 별 · 보낸 사람(받는 사람) · [메일함] · 제목 · 날짜 · 크기 (디자인 원본 BulkSelect · ListStates)
// 머리의 고르기 상자는 불러온 메일 모두 고르기 · 풀기 — 일부만 골랐으면 – 표시 (aria-checked="mixed")
// 대화로 묶은 메일함(14단계)은 불러온 대화 모두 고르기이고 크기 칸이 없다
// 칸 폭 · 칸 사이 여백은 표(MessageTable)가 정한다.
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -12,10 +14,13 @@ interface Props {
whoHeader: string
/** 전체 메일함 검색 결과 — 메일함 칸이 생긴다 */
mixed?: boolean
/** 대화로 묶은 메일함 — 크기 칸이 없다 */
threads?: boolean
}
withDefaults(defineProps<Props>(), {
mixed: false,
threads: false,
})
const mailStore = useMailStore()
@@ -33,7 +38,7 @@ const mailStore = useMailStore()
class="mail-table-head__check"
:checked="mailStore.checkState === 'all'"
:mixed="mailStore.checkState === 'some'"
:label="MAIL_BULK_TEXT.selectAll"
:label="threads ? MAIL_CONVERSATION_LIST_TEXT.selectAll : MAIL_BULK_TEXT.selectAll"
@toggle="mailStore.toggleCheckAll()"
/>
</th>
@@ -66,6 +71,7 @@ const mailStore = useMailStore()
날짜
</th>
<th
v-if="!threads"
scope="col"
class="mail-table__size"
>
@@ -13,6 +13,7 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때
export function useBulkMailActions() {
const mailStore = useMailStore()
@@ -22,10 +23,11 @@ export function useBulkMailActions() {
/** 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다 (읽음 · 중요 표시는 막지 않는다) */
const busy = ref<boolean>(false)
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 */
const allFlagged = computed<boolean>(
() => mailStore.checkedMessages.length > 0 && mailStore.checkedMessages.every((message) => message.isFlagged),
)
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 (대화 목록은 고른 줄이 모두 중요 표시가 있을 때) */
const allFlagged = computed<boolean>(() => {
const rows = mailStore.isConversationList ? mailStore.checkedConversations : mailStore.checkedMessages
return rows.length > 0 && rows.every((row) => row.isFlagged)
})
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
/** 스팸메일함을 보는 중 — 「스팸 신고」가 「스팸 아님」이 된다 (스팸메일함 안의 검색 포함) */
@@ -56,7 +58,7 @@ export function useBulkMailActions() {
const openId = mailStore.selectedId
const source = mailStore.listSource
if (result.stale || !openId || !source || !result.doneIds.includes(openId)) return
if (mailStore.messages.some((message) => message.id === openId)) return
if (mailStore.isListedMessage(openId)) return
await router.replace(listLocationOf(source))
}
@@ -0,0 +1,61 @@
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useToast } from './useToast'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import type { ConversationSummary, MailboxRole } from '@/shared/types/mail'
import { conversationWho, isConversationHeld } from '@/shared/utils/mail-conversation-list'
import { isDraftRole, isRowUnread, type RowWho } from '@/shared/utils/mail-list-row'
import { messageLocationOf } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
/**
* 대화로 묶은 메일함의 줄마다 필요한 값 (14단계 — 디자인 원본 ListThreads14) — 분할 보기 줄(ConversationListItem)과 목록 보기 표(ConversationTableRow)가 함께 쓴다.
* - 이름 자리: 참여자(내 주소는 "나" — 끝에), 보낸편지함 · 임시보관함에서 나뿐이면 "받는 사람: …" (mail-conversation-list.ts 의 conversationWho)
* - 안 읽음: 하나라도 안 읽었으면 굵게 — 임시보관함은 메일 목록처럼 굵게 하지 않는다
* - 누르면 갈 곳: 그 메일함의 마지막 메일 — 임시보관함이면 쓰기 화면에서 이어 쓴다(메일 목록과 같다)
* - 열린 메일이 든 줄: 마지막 메일이거나 줄에 든 메일([이 메일만 열기]로 연 더 오래된 메일)
* - 별: 줄의 메일 모두(붙잡힌 메일은 빼고 — 스토어). 줄의 중요 표시가 붙잡힌 메일에만 있어 풀리지 않았으면 그 까닭을 알린다
*/
export function useConversationRows() {
const mailStore = useMailStore()
const authStore = useAuthStore()
const toast = useToast()
/** 목록의 메일함 역할 — 묶은 목록은 늘 메일함 하나다 */
const role = computed<MailboxRole | null>(() => mailStore.currentMailbox?.role ?? null)
/** 임시보관함 — 제목 없는 저장본을 흐린 글자로 */
const isDraft = computed<boolean>(() => isDraftRole(role.value))
function whoOf(row: ConversationSummary, view: 'split' | 'table'): RowWho {
return conversationWho(row, { myEmail: authStore.user?.email, role: role.value, view })
}
function isUnread(row: ConversationSummary): boolean {
return isRowUnread({ isRead: row.isRead }, role.value)
}
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
function isHeld(row: ConversationSummary): boolean {
return isConversationHeld(row)
}
function locationOf(row: ConversationSummary): RouteLocationRaw {
if (isDraftRole(role.value)) return { name: 'mail-compose', query: { draft: row.latest.id } }
const source = mailStore.listSource
return source ? messageLocationOf(source, row.latest.id) : { name: 'mail' }
}
function isSelected(row: ConversationSummary): boolean {
const id = mailStore.selectedId
return id !== null && (row.latest.id === id || row.messageIds.includes(id))
}
async function toggleFlag(rowId: string): Promise<void> {
const { heldFlagged } = await mailStore.toggleConversationFlag(rowId)
if (heldFlagged) toast.error(MAIL_CONVERSATION_LIST_TEXT.heldFlagNote)
}
return { role, isDraft, whoOf, isUnread, isHeld, locationOf, isSelected, toggleFlag }
}
+21
View File
@@ -13,6 +13,8 @@ import {
import { MAIL_CONVERSATION_TIMEOUT_MS } from '@/shared/constants/mail-conversation'
import type {
Attachment,
ConversationListPage,
ConversationListQuery,
ConversationPage,
Draft,
DraftPayload,
@@ -52,6 +54,7 @@ import type {
} from '@/shared/types/mail'
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
import { conversationPageOf } from '@/shared/utils/mail-conversation'
import { conversationListPageOf } from '@/shared/utils/mail-conversation-list'
import { pendingSendOf } from '@/shared/utils/pending-send'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
@@ -294,6 +297,23 @@ export function useMail() {
return conversationPageOf(page)
}
/**
* 그 메일함을 대화로 묶은 목록 — 한 줄이 대화 하나, 그 메일함 안 마지막 받은 시각 최신부터 (14단계 — 계약 2절 "대화로 묶어 보기").
* 커서는 그 메일함 · 그 기준에서만 통한다. 엔진 방식은 한 요청이 15초까지 걸릴 수 있어 같은 대화와 같은 시간 한도를 쓴다.
* 응답은 모양을 맞춰 돌려준다 — 모양이 틀린 줄은 빼고, 마지막 메일은 늘 messageIds 에 든다
*/
async function listConversations(
query: ConversationListQuery,
options?: MailRequestOptions,
): Promise<ConversationListPage> {
const page = await api.get<unknown, unknown>('/conversations', {
timeout: MAIL_CONVERSATION_TIMEOUT_MS,
...options,
params: query,
})
return conversationListPageOf(page)
}
function updateMessage(
id: string,
changes: UpdateMessagePayload,
@@ -500,6 +520,7 @@ export function useMail() {
getSearchIndex,
getMessage,
getConversation,
listConversations,
updateMessage,
updateMessages,
deleteMessages,
+21 -6
View File
@@ -9,20 +9,35 @@ export type MailSettingsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'si
/** 경로에 넣는 값은 항상 인코딩한다 — 도메인 차단 "@spam.example" 은 "%40spam.example" */
const segment = (value: string) => encodeURIComponent(value)
/** GET · PATCH /mail-settings 가 실제로 주는 모양 — 14단계 앞의 서버는 groupByConversation 이 없다 */
type MailSettingsReply = Omit<MailSettings, 'groupByConversation'> & { groupByConversation?: unknown }
/** 설정 응답 → 화면의 값. groupByConversation 이 불리언 true 가 아니면 묶지 않는 것으로 본다 (처음 값 — 계약 2절 "보내기 · 읽기 설정") */
function mailSettingsOf(reply: MailSettingsReply): MailSettings {
// 응답을 바꾼 게이트웨이 등으로 객체가 아닐 수도 있다 — 그때도 던지지 않고 묶지 않는 것으로 본다
const value: unknown = reply
const grouped =
typeof value === 'object' && value !== null && (value as { groupByConversation?: unknown }).groupByConversation === true
return { ...reply, groupByConversation: grouped }
}
// 보내기 · 읽기 설정 · 보낸 사람 차단 API Composable — docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" · "보낸 사람 차단" (9단계, 모두 로그인 필요)
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
// 컴포넌트는 이것을 직접 부르지 않고 스토어(mail-settings.store · mail-block.store)를 거친다
export function useMailSettings() {
const api = useApi()
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기) */
function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
return api.get<MailSettings, MailSettings>('/mail-settings', options)
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기 · 묶지 않음) */
async function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
return mailSettingsOf(await api.get<MailSettingsReply, MailSettingsReply>('/mail-settings', options))
}
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체 */
function updateSettings(patch: Partial<MailSettings>, options?: MailSettingsRequestOptions): Promise<MailSettings> {
return api.patch<MailSettings, MailSettings>('/mail-settings', patch, options)
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체. groupByConversation 은 불리언만 받는다(그 밖은 400 VAL_001) */
async function updateSettings(
patch: Partial<MailSettings>,
options?: MailSettingsRequestOptions,
): Promise<MailSettings> {
return mailSettingsOf(await api.patch<MailSettingsReply, MailSettingsReply>('/mail-settings', patch, options))
}
/** 차단한 주소 · 도메인 — 최근에 차단한 것부터 500개까지 */
@@ -21,6 +21,7 @@ import { useMailStore } from '@/stores/mail.store'
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
// - 뺀 뒤(leaveRemoved): 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
// 대화로 묶은 메일함(14단계)은 같은 결로 그 줄의 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄의 마지막 메일 (스토어의 neighborAfterRemoval).
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
export interface MessageRemovalOutcome {
@@ -55,7 +56,7 @@ export function useMessageRemoval() {
}
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
const removed = done && !stale && !mailStore.isListedMessage(id)
return { removed, nextId: removed ? nextId : null }
}
@@ -42,7 +42,8 @@ export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) {
const watches = computed<PendingWatch[]>(() =>
pendingWatchesOf([
...mailStore.messages,
// 보이는 목록의 줄 — 대화로 묶은 메일함은 줄마다 마지막 메일 (14단계 — 줄 안의 다른 붙잡힌 메일은 시각을 몰라 다음 맞추기를 기다린다)
...mailStore.listedSummaries,
mailStore.selected,
mailStore.nextScheduled,
...mailStore.conversationItems,
@@ -74,7 +74,7 @@ export function useScheduledMessageActions() {
/** 목록에서 빠졌는가 — 빠졌으면 삭제처럼 다음 메일(분할 보기) · 목록(목록 보기)으로 */
function removedFromList(messageId: string): boolean {
return !mailStore.messages.some((message) => message.id === messageId)
return !mailStore.isListedMessage(messageId)
}
/** [예약 취소] — 보내지 않고 임시보관함으로 */
@@ -262,7 +262,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
it('보내기 · 읽기 설정을 받아 두었으면 설정한 초 − 1초에 닫는다 — 이 PC 시계가 늦어 보낼 시각이 멀리 보여도 늘어나지 않는다 (리뷰 #11)', async () => {
const settings = useMailSettingsStore()
settings.settings = { undoSendSeconds: 5, externalImages: 'ask' }
settings.settings = { undoSendSeconds: 5, externalImages: 'ask', groupByConversation: false }
settings.status = 'ready'
const far = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW + 600_000).toISOString() } }, 201)
const { wrapper } = await mountCompose(server([far]))
@@ -0,0 +1,585 @@
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, type Router } from 'vue-router'
import { CLOCK } from '@/composables/useClock'
import { routes } from '@/router/routes'
import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type {
ConversationSummary,
Mailbox,
MailSettings,
MessageDetail,
MessagesPatch,
MessageSummary,
} from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { groupByConversationHintKey } from '@/stores/mail-settings.store'
import { mailViewModeStorageKey } from '@/stores/mail-view.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'
// 14단계 3차 — 목록의 "대화로 묶어 보기" (디자인 원본 ListThreads14 · 계약 2절 "대화로 묶어 보기")
// 실제 메일 화면(MailLayout → MailboxPage → 목록 · 읽기 화면)을 띄운다 — 설정(groupByConversation)이 목록 출처를 정한다
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const ME = { name: '김아라', email: USER.email }
const DOYUN = { name: '이도윤', email: 'doyun.lee@arasystem.test' }
const HR = { name: '인사팀', email: 'hr@arasystem.test' }
const MINSU = { name: '김민수', email: 'minsu.kim@arasystem.test' }
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: `t-${id}`,
mailboxIds: ['inbox'],
from: DOYUN,
to: [ME],
subject: `제목 ${id}`,
preview: '',
// 한국 시간 · UTC 의 날짜가 같은 시각 (시간대와 상관없이 같은 글)
receivedAt: '2026-10-20T03:00:00Z',
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: [],
}
}
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? id),
participants: [DOYUN],
messageCount: messageIds.length,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
const ROWS: ConversationSummary[] = [
conversation('c1', ['m6', 'm5'], {
latest: summary('m6', { subject: 'Re: 전사 회의 참석 안내', isRead: false, hasAttachment: true }),
participants: [HR, ME, MINSU],
messageCount: 6,
isRead: false,
hasAttachment: true,
}),
conversation('c2', ['m4'], { latest: summary('m4', { subject: '11월 비용 정산 안내' }) }),
conversation('c3', ['m3', 'm2'], { latest: summary('m3', { subject: 'Re: 유지보수 계약 갱신 건' }), participants: [ME, DOYUN] }),
]
const page = (items: unknown[], total: number | null = items.length) =>
ok({ items, pageInfo: { hasNext: false, nextCursor: null, total } })
describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
let settings: MailSettings
let rows: ConversationSummary[]
let state: string
let patchSettings: ((request: MockRequest) => MockResult) | null
/** GET /mail-settings 의 응답을 바꾼다 (받지 못함 등) */
let loadSettings: (() => MockResult) | null
/** GET /messages 의 응답을 바꾼다 (붙잡아 두기 등) */
let listMessages: (() => MockResult | Promise<MockResult>) | null
const mounted: VueWrapper[] = []
beforeEach(() => {
window.localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(new Date(2026, 10, 6, 15).getTime())
settings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
rows = ROWS
state = 's1'
patchSettings = null
loadSettings = null
listMessages = null
requests = installMockApi((request) => {
const { url, method } = request
if (url === '/mail-state') return ok({ state })
if (url === '/mailboxes') {
return page([
mailbox('inbox', 'inbox', '받은편지함', 9, 2),
mailbox('drafts', 'drafts', '임시보관함', 1, 0),
mailbox('trash', 'trash', '휴지통', 0, 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' && method === 'GET') return loadSettings ? loadSettings() : ok(settings)
if (url === '/mail-settings' && method === 'PATCH') {
if (patchSettings) return patchSettings(request)
settings = { ...settings, ...(request.data as Partial<MailSettings>) }
return ok(settings)
}
if (url === '/scheduled-messages') return page([])
if (url === '/messages' && method === 'PATCH') {
const data = request.data as MessagesPatch
// 옮긴 메일은 서버의 묶은 목록에서도 빠진다 — 다시 맞춘 목록이 옛 줄을 되살리지 않게
if (data.mailboxId) {
rows = rows
.map((row) => {
const messageIds = row.messageIds.filter((id) => !data.ids.includes(id))
const latestId = messageIds[0]
return latestId && latestId !== row.latest.id ? { ...row, messageIds, latest: summary(latestId) } : { ...row, messageIds }
})
.filter((row) => row.messageIds.length > 0)
}
return ok({ updatedIds: data.ids, failedIds: [] })
}
if (url === '/message-deletions') return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
if (url === '/conversations') return ok({ items: rows, pageInfo: { hasNext: false, nextCursor: null, total: 6 }, basis: 'index' })
if (url === '/messages' && method === 'GET') {
return listMessages ? listMessages() : page([summary('m6', { isRead: false }), summary('m4')], 9)
}
if (url.endsWith('/conversation')) return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 1 }, basis: 'index' })
const opened = /^\/messages\/([^/]+)$/.exec(url)?.[1]
if (opened && method === 'GET') return ok(detail(summary(opened)))
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(path = '/mail/inbox'): Promise<{ wrapper: VueWrapper; router: Router }> {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push(path)
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, router }
}
/** 이 브라우저가 마지막으로 안 묶어 보기 값 — 설정을 받기 전의 짐작 (mail-settings.store) */
function rememberGrouped(grouped = true) {
window.localStorage.setItem(groupByConversationHintKey(USER.email), String(grouped))
}
const threadRequests = () => requests.filter((request) => request.url === '/conversations')
const messageListRequests = () => requests.filter((request) => request.url === '/messages' && request.method === 'GET')
const rowTexts = (wrapper: VueWrapper) =>
wrapper.findAll('.thread-row').map((row) => ({
who: row.get('.thread-row__who').text(),
count: row.find('[data-thread-count]').exists() ? row.get('[data-thread-count]').text() : null,
subject: row.get('.thread-row__subject').text(),
unread: row.classes('is-unread'),
}))
it('켜져 있으면 묶은 목록 — 참여자(나는 끝에) · 통 수(한 통이면 없음) · 마지막 메일 제목 · 하나라도 안 읽었으면 굵게, 머리는 대화 수', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
expect(threadRequests().map((request) => request.params)).toEqual([{ mailboxId: 'inbox', limit: 20 }])
// 짐작(이 브라우저가 마지막으로 안 값)이 있으면 메일 목록을 받았다 버리지 않는다
expect(messageListRequests()).toHaveLength(0)
expect(rowTexts(wrapper)).toEqual([
{ who: '안 읽음, 인사팀, 김민수, 나', count: '6통', subject: 'Re: 전사 회의 참석 안내', unread: true },
{ who: '이도윤', count: null, subject: '11월 비용 정산 안내', unread: false },
{ who: '이도윤, 나', count: '2통', subject: 'Re: 유지보수 계약 갱신 건', unread: false },
])
expect(wrapper.findAll('.thread-row')[0]?.find('[aria-label="첨부 있음"]').exists()).toBe(true)
expect(wrapper.get('.list-header__count').text()).toBe('대화 6개, 안 읽은 메일 2통')
const toggle = wrapper.get('[data-group-toggle]')
expect(toggle.attributes('aria-pressed')).toBe('true')
// 분할 보기의 목록 칸은 아이콘만 — 이름은 title · 화면 낭독
expect(toggle.attributes('title')).toBe('대화로 묶기')
expect(toggle.text()).toBe('대화로 묶기')
})
it('처음 이 브라우저(짐작 없음)면 메일 목록으로 열었다가 설정을 받으면 묶은 목록으로 — 받은 값을 짐작으로 기억한다', async () => {
const { wrapper } = await mountMail()
expect(messageListRequests()).toHaveLength(1)
expect(threadRequests()).toHaveLength(1)
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
expect(window.localStorage.getItem(groupByConversationHintKey(USER.email))).toBe('true')
})
it('짐작과 서버 값이 다르면 서버 값이 이긴다 — 다른 PC 에서 껐다', async () => {
rememberGrouped()
settings = { ...settings, groupByConversation: false }
const { wrapper } = await mountMail()
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(window.localStorage.getItem(groupByConversationHintKey(USER.email))).toBe('false')
})
it('[대화로 묶기]를 누르면 저장하고 곧바로 바꾼다 — 끄면 메일 목록과 "메일 9통, 안 읽은 메일 2통", 다시 켜면 처음 쪽부터', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.data)).toEqual([
{ groupByConversation: false },
])
expect(messageListRequests()).toHaveLength(1)
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(wrapper.get('.list-header__count').text()).toBe('메일 9통, 안 읽은 메일 2통')
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(threadRequests()).toHaveLength(2)
expect(threadRequests()[1]?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
})
it('저장하지 못하면 묶은 목록으로 되돌린다 — 알림은 인터셉터가', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
patchSettings = () => fail(400, 'VAL_001')
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('true')
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
})
it('줄을 누르면 그 메일함의 마지막 메일을 연다 — 연 메일이 든 줄이 골라진 모양', async () => {
rememberGrouped()
const { wrapper, router } = await mountMail()
await wrapper.findAll('.thread-row__main')[2]?.trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m3')
expect(wrapper.get('#reader-subject').text()).toBe('제목 m3')
expect(wrapper.findAll('.thread-row')[2]?.classes()).toContain('is-selected')
})
it('메일이 모두 보내기를 기다리는 줄은 고를 수 없다 — 고르기 칸 · 별이 aria-disabled 이고 누르면 아무것도 하지 않는다', async () => {
rememberGrouped()
rows = [conversation('held', ['m9'], { pendingSendIds: ['m9'] }), ...ROWS.slice(1)]
const { wrapper } = await mountMail()
const heldRow = wrapper.findAll('.thread-row')[0]
expect(heldRow?.get('[role="checkbox"]').attributes('aria-disabled')).toBe('true')
expect(heldRow?.get('[data-thread-star]').attributes('aria-disabled')).toBe('true')
await heldRow?.get('[role="checkbox"]').trigger('click')
await heldRow?.get('[data-thread-star]').trigger('click')
await settle()
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
expect(requests.filter((request) => request.method === 'PATCH')).toEqual([])
})
it('고르면 도구 줄 "대화 1개 선택됨" — 하는 일은 그 줄의 메일 모두', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
expect(wrapper.get('.bulk-bar__count').text()).toBe('대화 1개 선택됨')
await wrapper.get('[data-tool="markRead"]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m6', 'm5'], isRead: true },
])
expect(wrapper.findAll('.thread-row')[0]?.classes()).not.toContain('is-unread')
})
it('휴지통에서 고른 대화를 영구 삭제 — 확인 창의 수는 메일 수이고 그 줄의 메일을 POST /message-deletions', async () => {
rememberGrouped()
rows = [conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }) }), conversation('t2', ['m1'])]
const { wrapper } = await mountMail('/mail/trash')
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
await wrapper.get('[data-tool="delete"]').trigger('click')
await settle()
const dialog = wrapper.get('#bulk-purge-title')
expect(dialog.text()).toBe('메일 2통을 영구 삭제할까요?')
await wrapper.get('[data-purge-confirm]').trigger('click')
await settle()
expect(requests.filter((request) => request.url === '/message-deletions').map((request) => request.data)).toEqual([
{ ids: ['m8', 'm7'] },
])
expect(useToastStore().toasts.map((toast) => toast.message)).toContain('메일 2통을 영구 삭제했습니다.')
})
it('목록 보기 — 크기 칸이 없고 머리 고르기는 "불러온 대화 모두 고르기". 연 메일의 위치는 대화 기준이고 [다음]은 다음 대화의 마지막 메일', async () => {
rememberGrouped()
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
const { wrapper, router } = await mountMail()
const heads = wrapper.findAll('table.mail-table thead th').map((th) => th.text())
expect(heads).not.toContain('크기')
expect(wrapper.get('thead th [role="checkbox"]').attributes('aria-label')).toBe('불러온 대화 모두 고르기')
expect(wrapper.get('table.mail-table caption').text()).toBe('받은편지함 대화 목록')
expect(wrapper.findAll('tbody tr').map((row) => row.get('[data-message-id]').attributes('data-message-id'))).toEqual([
'm6',
'm4',
'm3',
])
// 목록 보기에서는 이름까지
expect(wrapper.get('[data-group-toggle]').attributes('title')).toBeUndefined()
await router.push('/mail/inbox/m4')
await settle()
expect(wrapper.get('.nav-position').text()).toBe('2 / 6')
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m3')
})
it('새 메일 확인 — 바뀌었으면 묶은 목록을 불러온 줄 수만큼 조용히 다시 받는다', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
expect(threadRequests()).toHaveLength(1)
rows = [conversation('c9', ['m9'], { latest: summary('m9', { subject: '새 대화' }) }), ...ROWS]
state = 's2'
clock.advance(MAIL_STATE_POLL_INTERVAL_MS)
await settle()
expect(threadRequests()).toHaveLength(2)
expect(threadRequests()[1]?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
expect(threadRequests()[1]?.config.silent).toBe(true)
expect(messageListRequests()).toHaveLength(0)
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c9', 'c1', 'c2', 'c3'])
})
describe('세션이 끝나면 목록을 부르지 않는다 (리뷰 M1)', () => {
it.each([
['로그아웃', () => useAuthStore().clearAuth()],
['세션 만료', () => useAuthStore().expireSession()],
])('묶기를 켠 채 %s — 메일 목록 · 묶은 목록 요청이 0건이고 비운 목록이 다시 차지 않는다', async (_name, end) => {
rememberGrouped()
await mountMail()
const before = { messages: messageListRequests().length, threads: threadRequests().length }
end()
await settle()
expect({ messages: messageListRequests().length, threads: threadRequests().length }).toEqual(before)
expect(useMailStore().conversations).toEqual([])
expect(useMailStore().listSource).toBeNull()
})
})
describe('읽기 화면 [삭제] 뒤 갈 곳 — 분할 보기 (리뷰 L3)', () => {
it('그 줄에 메일이 남으면 그 줄의 다음 칸(더 오래된 메일), 줄이 비면 다음 줄의 마지막 메일', async () => {
rememberGrouped()
const { wrapper, router } = await mountMail('/mail/inbox/m6')
await wrapper.get('[data-reader-delete]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m6'], mailboxId: 'trash' },
])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m5')
expect(wrapper.findAll('.thread-row')[0]?.classes()).toContain('is-selected')
await wrapper.get('[data-reader-delete]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c2', 'c3'])
})
})
describe('목록 보기의 읽기 화면 — 대화 사이를 오간다', () => {
beforeEach(() => {
rememberGrouped()
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
})
it('줄의 마지막 메일이 아닌 메일도 그 메일이 든 줄의 위치 — [다음]은 다음 대화, [이전]은 앞 대화의 마지막 메일', async () => {
const { wrapper, router } = await mountMail('/mail/inbox/m5')
expect(wrapper.get('.nav-position').text()).toBe('1 / 6')
expect(wrapper.get('button[aria-label="이전 메일"]').attributes('disabled')).toBeDefined()
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
await router.push('/mail/inbox/m2')
await settle()
expect(wrapper.get('.nav-position').text()).toBe('3 / 6')
await wrapper.get('button[aria-label="이전 메일"]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
})
it('[목록으로] — 표로 돌아가 그 메일이 든 줄의 링크(마지막 메일)로 초점을 돌려준다', async () => {
const { wrapper, router } = await mountMail('/mail/inbox/m5')
const back = wrapper.findAll('a').find((link) => link.text() === '목록으로')
await back?.trigger('click', { button: 0 })
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(document.activeElement?.getAttribute('data-message-id')).toBe('m6')
})
it('메일이 모두 보내기를 기다리는 줄 — Ctrl · Shift + 클릭으로도 고르지 않고, 날짜 칸은 예약 표시', async () => {
rows = [
conversation('held', ['m9'], {
latest: summary('m9', { pendingSend: { sendAt: '2026-11-10T01:00:00Z', kind: 'scheduled' } }),
pendingSendIds: ['m9'],
}),
...ROWS,
]
const { wrapper } = await mountMail()
const heldRow = wrapper.get('tr[data-row-id="held"]')
await heldRow.get('td.mail-table__subject').trigger('click', { ctrlKey: true })
await heldRow.get('td.mail-table__subject').trigger('click', { shiftKey: true })
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
expect(heldRow.get('td.mail-table__date').text()).toMatch(/^예약 · /)
// 다른 줄은 Ctrl + 클릭으로 고른다
await wrapper.get('tr[data-row-id="c2"] td.mail-table__subject').trigger('click', { ctrlKey: true })
expect(wrapper.get('.bulk-bar__count').text()).toBe('대화 1개 선택됨')
})
})
it('빠르게 켬 → 끔 → 켬 — 마지막 목록만 남고 버린 메일 목록 요청은 끊는다 (리뷰 L7)', async () => {
rememberGrouped()
let releaseMessages: (result: MockResult) => void = () => undefined
listMessages = () =>
new Promise<MockResult>((resolve) => {
releaseMessages = resolve
})
const { wrapper } = await mountMail()
const toggle = () => wrapper.get('[data-group-toggle]')
await toggle().trigger('click')
await settle()
expect(messageListRequests()).toHaveLength(1)
await toggle().trigger('click')
await settle()
expect(messageListRequests()[0]?.config.signal?.aborted).toBe(true)
releaseMessages(page([summary('late')], 1))
await settle()
expect(toggle().attributes('aria-pressed')).toBe('true')
expect(wrapper.findAll('.message-row')).toHaveLength(0)
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c1', 'c2', 'c3'])
expect(settings.groupByConversation).toBe(true)
})
it('설정을 받지 못했으면 짐작대로 묶고 [대화로 묶기]도 쓸 수 있다 — 저장 응답을 받은 설정으로 (리뷰 L4)', async () => {
rememberGrouped()
loadSettings = () => fail(503, 'SYS_001')
const { wrapper } = await mountMail()
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('true')
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/mail-settings').map((request) => request.data)).toEqual([
{ groupByConversation: false },
])
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
})
it('줄의 중요 표시가 보내기를 기다리는 메일에만 있으면 별로 풀리지 않는다 — 다시 맞춰 보고 그 까닭을 알린다', async () => {
rememberGrouped()
rows = [conversation('hf', ['m9', 'm8'], { isFlagged: true, pendingSendIds: ['m9'] }), ...ROWS.slice(1)]
const { wrapper } = await mountMail()
const syncs = threadRequests().length
await wrapper.get('.thread-row[data-row-id="hf"] [data-thread-star]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m8'], isFlagged: false },
])
expect(threadRequests().length).toBe(syncs + 1)
expect(wrapper.get('.thread-row[data-row-id="hf"] [data-thread-star]').attributes('aria-pressed')).toBe('true')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
'보내기를 기다리는 메일은 목록에서 중요 표시를 바꿀 수 없습니다. 메일을 열어 바꿔 주세요.',
])
})
it('임시보관함의 제목 없는 저장본 — 흐린 "(제목 없음)", 굵게 하지 않고 누르면 쓰기 화면에서 이어 쓴다 (리뷰 L5)', async () => {
rememberGrouped()
rows = [
conversation('d1', ['d1'], {
latest: summary('d1', { subject: '', mailboxIds: ['drafts'], isRead: false }),
participants: [ME],
isRead: false,
}),
]
const { wrapper } = await mountMail('/mail/drafts')
const row = wrapper.get('.thread-row')
expect(row.get('.thread-row__subject').text()).toBe('(제목 없음)')
expect(row.get('.thread-row__subject').classes()).toContain('is-placeholder')
expect(row.classes()).not.toContain('is-unread')
expect(row.get('.thread-row__who').text()).toBe('받는 사람: 김아라')
expect(row.get('.thread-row__main').attributes('href')).toBe('/mail/compose?draft=d1')
})
it('검색 결과는 묶지 않는다 — 켜져 있어도 GET /messages 이고 [대화로 묶기]가 없다', async () => {
rememberGrouped()
const { wrapper } = await mountMail('/mail/search?q=%ED%9A%8C%EC%9D%98&in=inbox')
expect(threadRequests()).toHaveLength(0)
expect(messageListRequests()[0]?.params).toMatchObject({ q: '회의', mailboxId: 'inbox' })
expect(wrapper.find('[data-group-toggle]').exists()).toBe(false)
})
})
+14 -3
View File
@@ -7,6 +7,10 @@
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
// 검색 결과는 메일함 목록과 같은 목록 · 표 · 본문을 쓴다 — 목록의 출처만 다르다 (mail-list-source.ts)
// 예약됨(9단계)은 고르기 · 여러 통 처리가 없는 목록(ScheduledMessageList — 받는 사람 · 제목 · 보낼 시각)을 쓰고, 연 메일은 같은 본문이다
// 대화로 묶어 보기(14단계 — 설정 groupByConversation)를 켰으면 메일함 목록의 출처가 GET /conversations 다 — 주소는 같고 한 줄이 대화 하나다.
// 설정을 받기 전 · 받지 못했으면 이 브라우저의 짐작(mail-settings.store — 처음이면 끔)으로 연다(기다리게 하지 않는다) — 받아 보니 다르면 그때 바꾼다.
// 켜고 끄면(목록 머리의 [대화로 묶기] · 설정 화면) 앞 목록의 요청을 끊고 처음 쪽부터 받는다. 검색 결과 · 예약됨은 묶지 않는다.
// 로그아웃 · 세션 만료 · 제한된 세션(canUseApp false)에는 목록을 부르지 않는다 — 떠나는 화면이 막 비운 목록을 다시 채우지 않게 (리뷰 M1)
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -20,12 +24,16 @@ import ScheduledMessageList from '@/components/mail/ScheduledMessageList.vue'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { isScheduledRouteName, isSearchTooLong, listKeyOf } from '@/shared/utils/mail-list-source'
import { messageHighlightKey, messageHighlightQueryOf } from '@/shared/utils/mail-search'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailViewStore } from '@/stores/mail-view.store'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const mailStore = useMailStore()
const settingsStore = useMailSettingsStore()
const viewStore = useMailViewStore()
const { isSearching, routeSearch, closeSearch } = useMailSearchNavigation()
@@ -41,10 +49,13 @@ const isListView = computed(() => viewStore.mode === 'list')
/** 목록이 있는 화면 — 메일함 · 검색 결과 · 예약됨 */
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value || isScheduled.value)
// 메일함 · 묶어 보기가 바뀔 때 — 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 스토어가 보이던 채로 새로 고친다.
// 세션이 끝났으면 부르지 않는다 (머리말 — 로그아웃은 스토어를 비우고 로그인 화면으로 간다)
watch(
mailboxId,
(id) => {
if (id) void mailStore.openMailbox(id)
[mailboxId, () => settingsStore.groupByConversation, () => authStore.canUseApp],
([id, grouped, usable]) => {
if (!id || !usable) return
void (grouped ? mailStore.openConversations(id) : mailStore.openMailbox(id))
},
{ immediate: true },
)
@@ -13,10 +13,10 @@ import MailSendReadPage from './MailSendReadPage.vue'
// 설정 › 메일 › 보내기 · 읽기 (디자인 원본 SettingsSendRead · 계약 2절 "보내기 · 읽기 설정") — 고르는 대로 PATCH, "저장했습니다"
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask' }
const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false }
interface Server {
settings?: MailSettings
settings?: Partial<MailSettings>
/** GET /mail-settings 의 응답을 바꾼다 (실패 등) */
load?: MockResult
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
@@ -118,6 +118,37 @@ describe('MailSendReadPage', () => {
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('대화로 묶어 보기(14단계) — 끔 · 켬 전환과 설명, 고르면 그 칸만 PATCH 하고 "저장했습니다"', async () => {
const wrapper = await mountPage()
const grouping = () =>
wrapper.findAll('[data-group-by-conversation]').map((button) => [button.text(), button.attributes('aria-pressed')])
expect(wrapper.get('#send-read-group-title').text()).toBe('대화로 묶어 보기')
expect(grouping()).toEqual([
['끔', 'true'],
['켬', 'false'],
])
expect(wrapper.get('#send-read-group-hint').text()).toBe(
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 목록 위의 [대화로 묶기]로도 켜고 끌 수 있습니다.',
)
await wrapper.get('[data-group-by-conversation="true"]').trigger('click')
await flushPromises()
expect(patches()).toEqual([{ groupByConversation: true }])
expect(grouping()).toEqual([
['끔', 'false'],
['켬', 'true'],
])
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('묶어 보기 필드가 없는 서버(14단계 앞)면 끔으로 그린다', async () => {
const wrapper = await mountPage({ load: ok({ undoSendSeconds: 10, externalImages: 'ask' }) })
expect(wrapper.get('[data-group-by-conversation="false"]').attributes('aria-pressed')).toBe('true')
})
it('지금 값을 다시 누르면 보내지 않는다', async () => {
const wrapper = await mountPage()
@@ -3,14 +3,20 @@
// — /settings/mail/send-read · 탭 제목 "보내기 · 읽기"
// 머리 "보내기 · 읽기" + 설명 + 오른쪽에 "저장했습니다"(role=status — 저장 단추 없음) →
// 「보내기 취소」 전환(36 — .segmented 밀도 예외: 끔 · 5초 · 10초 · 20초 · 30초) + 설명 → 가는 선 →
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자.
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자 → 가는 선 →
// 「대화로 묶어 보기」 전환(36 — 끔 · 켬, 14단계 — 목록 머리의 [대화로 묶기]와 같은 설정) + 설명.
// - 들어올 때마다 새로 받는다(다른 PC 에서 바꿨을 수 있다 — 서버에 저장되어 어느 PC 에서나 같다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
// - 고르는 대로 그 칸만 PATCH 한다(mail-settings.store — 화면을 먼저 바꾸고 실패하면 고르기 전 값으로 되돌린다, 알림은 인터셉터).
// 저장이 끝나면 "저장했습니다", 다시 고르면 내린다
import AppIcon from '@/components/AppIcon.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { EXTERNAL_IMAGES_OPTIONS, MAIL_SETTINGS_TEXT, UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
import {
EXTERNAL_IMAGES_OPTIONS,
GROUP_BY_CONVERSATION_OPTIONS,
MAIL_SETTINGS_TEXT,
UNDO_SEND_OPTIONS,
} from '@/shared/constants/mail-settings'
import type { ExternalImagesSetting, UndoSendSeconds } from '@/shared/types/mail'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
@@ -28,6 +34,10 @@ function chooseImages(option: ExternalImagesSetting) {
void settingsStore.update('externalImages', option)
}
function chooseGrouping(grouped: boolean) {
void settingsStore.update('groupByConversation', grouped)
}
// 처음 그리기 전에 — 지난번에 받아 둔 값을 잠깐 그렸다 바꾸지 않게
void settingsStore.load()
</script>
@@ -135,6 +145,44 @@ void settingsStore.load()
{{ MAIL_SETTINGS_TEXT.imagesNote }}
</p>
</fieldset>
<hr class="send-read__rule">
<section
class="send-read__group"
aria-labelledby="send-read-group-title"
>
<h2
id="send-read-group-title"
class="send-read__title"
>
{{ MAIL_SETTINGS_TEXT.groupTitle }}
</h2>
<div
role="group"
class="segmented segmented--outline send-read__undo"
aria-labelledby="send-read-group-title"
aria-describedby="send-read-group-hint"
>
<button
v-for="grouped in GROUP_BY_CONVERSATION_OPTIONS"
:key="String(grouped)"
type="button"
class="segmented__item"
:aria-pressed="settingsStore.settings.groupByConversation === grouped"
:data-group-by-conversation="String(grouped)"
@click="chooseGrouping(grouped)"
>
{{ grouped ? MAIL_SETTINGS_TEXT.groupOn : MAIL_SETTINGS_TEXT.groupOff }}
</button>
</div>
<p
id="send-read-group-hint"
class="form-message"
>
{{ MAIL_SETTINGS_TEXT.groupHint }}
</p>
</section>
</template>
<div
+3
View File
@@ -344,6 +344,9 @@ export const ICONS = {
line(9, 8, 15, 8),
line(17, 16, 23, 16),
],
// ── 14단계: 대화로 묶어 보기 (디자인 원본 ListThreads14) ──
/** 목록 머리의 [대화로 묶기] (말풍선) */
'message-square': [path('M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z')],
} as const satisfies Record<string, readonly IconShape[]>
export type IconName = keyof typeof ICONS
@@ -1,4 +1,5 @@
// 같은 대화 — 읽기 화면의 "같은 대화" 칸 (14단계 — 계약 2절 "대화 — 14단계" · 디자인 README 「14단계 — 검색 · 대화 묶기」 › "읽기 화면 — 같은 대화 (Thread14)")
// 와 목록의 "대화로 묶어 보기" (같은 README › "목록 — 대화로 묶어 보기 (ListThreads14)" — 아래 MAIL_CONVERSATION_LIST_TEXT)
// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다 — 이 파일이 원본이다.
/** 한 번에 받는 수 — 계약의 처음 값이자 최대(100). 대화는 대개 한 쪽이다 */
@@ -52,3 +53,25 @@ export const MAIL_CONVERSATION_TEXT = {
retry: '다시 시도',
traceCode: (code: string) => `문의 코드 ${code}`,
} as const
// ── 목록 — 대화로 묶어 보기 (디자인 README 「14단계 — 검색 · 대화 묶기」 › "목록 — 대화로 묶어 보기 (ListThreads14)") ─────────
/** 목록 머리 · 대화 줄 · 여러 통 처리의 글 (디자인 원본 ListThreads14 — 화면 문구는 README 「화면 문구 — 사용자에게 말하듯」) */
export const MAIL_CONVERSATION_LIST_TEXT = {
/** 목록 머리 오른쪽의 눌림 단추 (aria-pressed) — 분할 보기에서는 아이콘만이라 title 도 이 글이다 */
toggle: '대화로 묶기',
/** 목록 머리 글 — 켰을 때 "대화 6개, 안 읽은 메일 2통", 껐을 때 "메일 9통, 안 읽은 메일 2통" (안 읽은 메일이 없으면 뒤를 뺀다) */
conversationCount: (count: string) => `대화 ${count}개`,
messageCount: (count: string) => `메일 ${count}통`,
unreadCount: (count: string) => `안 읽은 메일 ${count}통`,
/** 대화 줄의 통 수 — 회색 글 (한 통이면 없다) */
rowCount: (count: string) => `${count}통`,
/** 여러 통 처리 도구 줄의 머리 — 고른 것은 대화다 */
selected: (count: string) => `대화 ${count}개 선택됨`,
/** 표 머리의 고르기 상자 */
selectAll: '불러온 대화 모두 고르기',
/** 목록 보기 표의 caption — 메일 목록은 "받은편지함 메일 목록" */
tableCaption: (mailbox: string) => `${mailbox} 대화 목록`,
/** 줄의 메일이 모두 보내기를 기다리는 메일일 때 별(중요 표시)의 까닭 — 고르기 칸은 메일 줄과 같은 글(SEND_LATER_TEXT.heldNotSelectable) */
heldFlagNote: '보내기를 기다리는 메일은 목록에서 중요 표시를 바꿀 수 없습니다. 메일을 열어 바꿔 주세요.',
} as const
@@ -8,6 +8,9 @@ export const UNDO_SEND_OPTIONS: readonly UndoSendSeconds[] = [0, 5, 10, 20, 30]
/** 외부 이미지 — 고르는 차례 그대로 */
export const EXTERNAL_IMAGES_OPTIONS: readonly ExternalImagesSetting[] = ['ask', 'trusted', 'always']
/** 대화로 묶어 보기 (14단계) — 고르는 차례 그대로 (끔 · 켬 — 보내기 취소처럼 끔이 앞) */
export const GROUP_BY_CONVERSATION_OPTIONS: readonly boolean[] = [false, true]
export const MAIL_SETTINGS_TEXT = {
title: '보내기 · 읽기',
/** 보드에 없다 — 저장 단추가 없으니 고르는 대로 저장된다는 것을 머리에서 알린다 */
@@ -31,4 +34,11 @@ export const MAIL_SETTINGS_TEXT = {
} satisfies Record<ExternalImagesSetting, { label: string; hint: string }>,
imagesNote:
'외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
/** 대화로 묶어 보기 (14단계 — 디자인 README "목록 — 대화로 묶어 보기 (ListThreads14)" 의 켬/끔 한 줄. 보드가 없어 글은 정했다) */
groupTitle: '대화로 묶어 보기',
groupOff: '끔',
groupOn: '켬',
groupHint:
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 목록 위의 [대화로 묶기]로도 켜고 끌 수 있습니다.',
} as const
+13
View File
@@ -240,6 +240,19 @@ export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다.
/** 임시보관함 목록 — 받는 사람 · 제목이 없을 때 (디자인 원본 ComposeAssist "임시보관함 목록") */
export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
export const MAIL_NO_SUBJECT = '(제목 없음)'
/**
* 목록 줄의 화면 낭독 · 아이콘 이름 — 대화 줄(14단계 — ConversationListItem · ConversationTableRow)이 쓴다.
* 메일 줄(MessageListItem · MessageTableRow)도 같은 글이다 — 고칠 때는 함께
*/
export const MAIL_LIST_ROW_TEXT = {
/** 안 읽은 줄의 이름 자리 앞 (화면에는 보이지 않는다) */
unreadPrefix: '안 읽음, ',
/** 별 단추 */
flag: '중요 표시',
/** 첨부 클립 */
attachment: '첨부 있음',
} as const
/** 보낸 사람이 없는 메일 — 목록 · 읽기 화면 · 같은 대화 칸의 이름 자리 */
export const MAIL_NO_SENDER = '(보낸 사람 없음)'
+44 -1
View File
@@ -216,6 +216,44 @@ export interface ConversationPage extends PaginatedResult<MessageSummary> {
basis: ConversationBasis | null
}
/**
* GET /conversations 의 한 줄 — 그 메일함 안의 대화 하나 (14단계 — 계약 2절 "대화로 묶어 보기").
* 고르기 · 읽음 · 중요 · 이동 · 삭제는 messageIds 에서 pendingSendIds 를 뺀 메일에 한다 (붙잡힌 메일은 취소로만 보낸편지함을 떠난다)
*/
export interface ConversationSummary {
/** 대화 id — 불투명(기준마다 다르다). 한 목록 안에서 줄을 가리는 데만 쓴다 */
id: string
/** 그 메일함 안의 마지막 메일 — 줄의 제목 · 시각 · 붙잡은 발송, 누르면 연다 */
latest: MessageSummary
/** 대화 전체의 보낸 사람 — 받은 차례, 주소마다 한 번, 10명까지 (화면은 내 주소를 "나"로) */
participants: Address[]
/** 대화 전체의 메일 수 — 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일은 뺀다. 엔진 방식에서 상한에 걸린 줄은 어림이다 */
messageCount: number
/** 그 메일함 안의 그 대화 메일 — 최신부터 500통까지 (useMail 이 마지막 메일을 늘 넣어 둔다) */
messageIds: string[]
/** messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약) — 없으면 빈 배열 */
pendingSendIds: string[]
/** 그 메일함 안의 메일을 모두 읽었다 */
isRead: boolean
/** 그 메일함 안에 중요 표시한 메일이 하나라도 있다 */
isFlagged: boolean
/** 그 메일함 안에 첨부가 있는 메일이 하나라도 있다 */
hasAttachment: boolean
}
/** GET /conversations 쿼리 — 검색 쿼리(q · 조건)는 받지 않는다 */
export interface ConversationListQuery extends CursorPaginationQuery {
mailboxId: string
}
/**
* GET /conversations 응답 — 그 메일함 안 마지막 받은 시각 최신부터. total 은 index 면 첫 쪽에서만(그 메일함의 대화 수), engine 이면 쪽마다.
* 모양이 틀린 줄은 뺀다 (useMail().listConversations)
*/
export interface ConversationListPage extends PaginatedResult<ConversationSummary> {
basis: ConversationBasis | null
}
/** PATCH /messages/{id} 본문 — 하나 이상 지정한다 */
export interface UpdateMessagePayload {
isRead?: boolean
@@ -334,10 +372,15 @@ export type UndoSendSeconds = 0 | 5 | 10 | 20 | 30
/** 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 */
export type ExternalImagesSetting = 'ask' | 'trusted' | 'always'
/** GET · PATCH /mail-settings — 직원마다 서버 DB 에 하나(어느 PC 에서나 같다). 처음 값은 10초 · 늘 묻기 */
/** GET · PATCH /mail-settings — 직원마다 서버 DB 에 하나(어느 PC 에서나 같다). 처음 값은 10초 · 늘 묻기 · 묶지 않음 */
export interface MailSettings {
undoSendSeconds: UndoSendSeconds
externalImages: ExternalImagesSetting
/**
* 메일함 목록을 대화로 묶어 보기 (14단계 — 계약 2절 "대화 — 14단계"). 서버는 저장해 돌려주기만 하고 묶은 목록을 부를지는 화면이 정한다.
* 필드가 없는 서버(14단계 앞)면 false 로 본다 (useMailSettings 가 맞춘다)
*/
groupByConversation: boolean
}
// ── 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) ─────────────
@@ -0,0 +1,327 @@
import { describe, expect, it } from 'vitest'
import type { ConversationSummary, MessageSummary } from '@/shared/types/mail'
import type { PageInfo } from '@/shared/types/pagination'
import {
appendConversationPage,
conversationActionIds,
conversationActionIdsOf,
conversationCountText,
conversationListPageOf,
conversationNeighborAfterRemoval,
conversationParticipantNames,
conversationRowIndexOf,
conversationWho,
isConversationHeld,
listHeadCountText,
mergeRefreshedConversations,
withConversationFlags,
withConversationFlagsOf,
withoutConversationMessages,
} from './mail-conversation-list'
// 대화로 묶어 보기 — 묶은 목록의 줄 (14단계 — 계약 2절 "대화로 묶어 보기" · 디자인 원본 ListThreads14)
const ME = 'ara.kim@arasystem.test'
const DOYUN = { name: '이도윤', email: 'doyun.lee@arasystem.test' }
const HR = { name: '인사팀', email: 'hr@arasystem.test' }
const MINSU = { name: '김민수', email: 'minsu.kim@arasystem.test' }
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: DOYUN,
to: [{ name: '김아라', email: ME }],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-11-05T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? `${id}-latest`),
participants: [DOYUN],
messageCount: messageIds.length,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
const ids = (rows: readonly { id: string }[]) => rows.map((row) => row.id)
const INFO = (partial: Partial<PageInfo> = {}): PageInfo => ({ hasNext: false, nextCursor: null, total: null, ...partial })
describe('conversationListPageOf — 응답 맞추기', () => {
it('계약의 모양이면 그대로 — 기준(basis)도 싣는다', () => {
const row = conversation('c1', ['m2', 'm1'], { participants: [DOYUN, { name: null, email: ME }] })
const page = conversationListPageOf({
items: [row],
pageInfo: { hasNext: true, nextCursor: 'next', total: 6 },
basis: 'engine',
})
expect(page.items).toEqual([row])
expect(page.pageInfo).toEqual({ hasNext: true, nextCursor: 'next', total: 6 })
expect(page.basis).toBe('engine')
})
it('목록 모양이 틀리면 빈 목록, 줄 모양이 틀리면 그 줄만 뺀다 (id 없음 · 마지막 메일이 요약 모양이 아님)', () => {
expect(conversationListPageOf(null).items).toEqual([])
expect(conversationListPageOf({ items: 'x', pageInfo: {} }).items).toEqual([])
const page = conversationListPageOf({
items: [conversation('ok', ['m1']), { id: '', latest: summary('m2') }, { id: 'c3', latest: { id: 'm3' } }, 'c4'],
pageInfo: { hasNext: false, nextCursor: null, total: 4 },
basis: 'index',
})
expect(ids(page.items)).toEqual(['ok'])
})
it('마지막 메일은 늘 messageIds 에 들고, 틀린 칸은 맞춘다 — 통 수가 정수가 아니면 messageIds 의 수, 불리언이 아니면 마지막 메일의 값', () => {
const [row] = conversationListPageOf({
items: [
{
id: 'c1',
latest: summary('m3', { isRead: false, isFlagged: true, hasAttachment: true }),
participants: [DOYUN, { email: 3 }, { name: undefined, email: ME }],
messageCount: 2.5,
messageIds: ['m1', 7, ''],
pendingSendIds: 'x',
},
],
pageInfo: { hasNext: true, nextCursor: '', total: -1 },
basis: 'thread',
}).items
expect(row?.messageIds).toEqual(['m3', 'm1'])
expect(row?.messageCount).toBe(2)
expect(row?.pendingSendIds).toEqual([])
expect(row?.participants).toEqual([DOYUN, { name: null, email: ME }])
expect([row?.isRead, row?.isFlagged, row?.hasAttachment]).toEqual([false, true, true])
})
it('커서 없는 hasNext 는 다음 쪽이 없는 것, 음수 total 은 모르는 것, 모르는 basis 는 null', () => {
const page = conversationListPageOf({ items: [], pageInfo: { hasNext: true, nextCursor: '', total: -1 }, basis: 'thread' })
expect(page.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: null })
expect(page.basis).toBeNull()
})
})
describe('줄에 하는 일 — 붙잡힌 메일은 뺀다', () => {
it('pendingSendIds 와 마지막 메일의 pendingSend 를 뺀 메일이 할 일이다', () => {
const row = conversation('c1', ['m3', 'm2', 'm1'], {
latest: summary('m3', { pendingSend: { sendAt: '2026-11-06T01:00:00Z', kind: 'undo' } }),
pendingSendIds: ['m2'],
})
expect(conversationActionIds(row)).toEqual(['m1'])
expect(isConversationHeld(row)).toBe(false)
})
it('줄의 메일이 모두 붙잡힌 메일이면 고를 수 없다', () => {
const row = conversation('c1', ['m2', 'm1'], { pendingSendIds: ['m2', 'm1'] })
expect(conversationActionIds(row)).toEqual([])
expect(isConversationHeld(row)).toBe(true)
})
it('여러 줄의 할 일은 겹치지 않게 모은다 (같은 id 를 두 번 보내면 서버가 거절한다)', () => {
const rows = [conversation('c1', ['m3', 'm2']), conversation('c2', ['m2', 'm1'], { pendingSendIds: ['m1'] })]
expect(conversationActionIdsOf(rows)).toEqual(['m3', 'm2'])
})
it('메일의 줄 — 마지막 메일이거나 줄에 든 메일, 없으면 -1', () => {
const rows = [conversation('c1', ['m3', 'm2']), conversation('c2', ['m1'])]
expect(conversationRowIndexOf(rows, 'm2')).toBe(0)
expect(conversationRowIndexOf(rows, 'm1')).toBe(1)
expect(conversationRowIndexOf(rows, 'zz')).toBe(-1)
})
})
describe('줄의 글', () => {
it('참여자 — 다른 사람은 받은 차례, 내 주소는 "나"로 끝에, 같은 이름은 한 번 (디자인 원본 ListThreads14 "인사팀, 김민수, 나")', () => {
const row = conversation('c1', ['m1'], {
// 내 주소는 대소문자 · 앞뒤 공백을 가리지 않는다
participants: [HR, { name: '김아라', email: ' Ara.Kim@Arasystem.TEST ' }, MINSU, { ...MINSU, email: 'm2@x.test' }],
})
expect(conversationParticipantNames(row, ME)).toEqual(['인사팀', '김민수', '나'])
})
it('이름 자리 — 받은편지함은 참여자를 쉼표로, 이름이 없으면 주소', () => {
const row = conversation('c1', ['m1'], { participants: [{ name: ME, email: ME }, { name: null, email: 'x@ext.test' }] })
expect(conversationWho(row, { myEmail: ME, role: 'inbox', view: 'split' })).toEqual({ text: 'x@ext.test, 나', placeholder: false })
})
it('보낸편지함 · 임시보관함에서 참여자가 나뿐이면 마지막 메일의 받는 사람 ("받는 사람: …")', () => {
const sentRow = conversation('c1', ['m1'], {
participants: [{ name: '김아라', email: ME }],
latest: summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME }, to: [DOYUN, MINSU] }),
})
expect(conversationWho(sentRow, { myEmail: ME, role: 'sent', view: 'table' }).text).toBe('받는 사람: 이도윤 외 1명')
const draftRow = conversation('c2', ['d1'], { participants: [], latest: summary('d1', { to: [] }) })
expect(conversationWho(draftRow, { myEmail: ME, role: 'drafts', view: 'split' })).toEqual({
text: '(받는 사람 없음)',
placeholder: true,
})
// 답장이 와서 다른 사람이 있으면 참여자 그대로
const replied = conversation('c3', ['m2', 'm1'], { participants: [{ name: '김아라', email: ME }, DOYUN] })
expect(conversationWho(replied, { myEmail: ME, role: 'sent', view: 'split' }).text).toBe('이도윤, 나')
})
it('받은편지함에서 참여자가 없으면 마지막 메일의 보낸 사람, 나뿐이면 "나"', () => {
expect(conversationWho(conversation('c1', ['m1'], { participants: [] }), { myEmail: ME, role: 'inbox', view: 'split' }).text).toBe(
'이도윤',
)
expect(
conversationWho(conversation('c2', ['m1'], { participants: [{ name: '김아라', email: ME }] }), {
myEmail: ME,
role: null,
view: 'split',
}).text,
).toBe('나')
})
it('통 수 — 한 통이면 없고, 여러 통이면 "4통" (천 단위 쉼표)', () => {
expect(conversationCountText(conversation('c1', ['m1']))).toBeNull()
expect(conversationCountText(conversation('c2', ['m1'], { messageCount: 4 }))).toBe('4통')
expect(conversationCountText(conversation('c3', ['m1'], { messageCount: 1234 }))).toBe('1,234통')
})
it('목록 머리 글 — 켰을 때 "대화 6개, 안 읽은 메일 2통", 껐을 때 "메일 9통, 안 읽은 메일 2통" (모르는 수 · 0 은 뺀다)', () => {
expect(listHeadCountText({ grouped: true, total: 6, unread: 2 })).toBe('대화 6개, 안 읽은 메일 2통')
expect(listHeadCountText({ grouped: false, total: 9, unread: 2 })).toBe('메일 9통, 안 읽은 메일 2통')
expect(listHeadCountText({ grouped: true, total: null, unread: 2 })).toBe('안 읽은 메일 2통')
expect(listHeadCountText({ grouped: true, total: 1500, unread: 0 })).toBe('대화 1,500개')
expect(listHeadCountText({ grouped: true, total: null, unread: 0 })).toBe('')
})
})
describe('이 탭이 바꾼 표시', () => {
it('줄의 메일을 모두 바꾸면 줄 전체가 읽음 · 중요 해제 — 마지막 메일의 표시도', () => {
const row = conversation('c1', ['m2', 'm1'], { isRead: false, isFlagged: true })
const read = withConversationFlags(row, new Set(['m2', 'm1']), { isRead: true, isFlagged: false })
expect([read.isRead, read.isFlagged]).toEqual([true, false])
expect([read.latest.isRead, read.latest.isFlagged]).toEqual([true, false])
})
it('일부만 바꾸면 읽음 · 중요 해제는 모른다(그대로) — 안 읽음 · 중요는 한 통만 바꿔도 줄이 그렇다', () => {
const row = conversation('c1', ['m2', 'm1'], { isRead: false, isFlagged: true })
const partial = withConversationFlags(row, new Set(['m2']), { isRead: true, isFlagged: false })
expect([partial.isRead, partial.isFlagged]).toEqual([false, true])
expect(partial.latest.isRead).toBe(true)
const readRow = conversation('c2', ['m4', 'm3'])
const unread = withConversationFlags(readRow, new Set(['m3']), { isRead: false, isFlagged: true })
expect([unread.isRead, unread.isFlagged]).toEqual([false, true])
// 마지막 메일이 아닌 메일은 마지막 메일의 표시를 바꾸지 않는다
expect(unread.latest).toBe(readRow.latest)
})
it('읽음은 붙잡힌 메일을 빼고 센다 — 나머지를 모두 바꿨으면 줄 전체다. 든 메일이 없으면 같은 줄', () => {
const row = conversation('c1', ['m2', 'm1'], { isRead: false, pendingSendIds: ['m2'] })
expect(withConversationFlags(row, new Set(['m1']), { isRead: true }).isRead).toBe(true)
expect(withConversationFlags(row, new Set(['zz']), { isRead: true })).toBe(row)
})
it('중요 해제는 붙잡힌 메일의 표시를 몰라 그 메일까지 바꿨을 때만 줄 전체다 — 줄의 중요 표시가 붙잡힌 메일에만 있을 수 있다 (리뷰 L1)', () => {
const row = conversation('c1', ['m2', 'm1'], { isFlagged: true, pendingSendIds: ['m2'] })
expect(withConversationFlags(row, new Set(['m1']), { isFlagged: false }).isFlagged).toBe(true)
expect(withConversationFlags(row, new Set(['m2', 'm1']), { isFlagged: false }).isFlagged).toBe(false)
})
it('되돌리기 — 줄과(마지막 메일이 그대로면) 마지막 메일의 표시를 앞 값으로', () => {
const original = conversation('c1', ['m2', 'm1'], { isRead: false, latest: summary('m2', { isRead: false }) })
const changed = withConversationFlags(original, new Set(['m2', 'm1']), { isRead: true })
expect([changed.isRead, changed.latest.isRead]).toEqual([true, true])
const restored = withConversationFlagsOf(changed, original)
expect([restored.isRead, restored.latest.isRead]).toEqual([false, false])
const newLatest = { ...changed, latest: summary('m9', { isRead: true }) }
expect(withConversationFlagsOf(newLatest, original).latest.isRead).toBe(true)
})
})
describe('뺀 메일 · 이어서 열 메일', () => {
it('메일이 모두 빠진 줄은 빼고 그 수를 센다 — 남은 줄은 메일만 줄인다', () => {
const rows = [conversation('c1', ['m3', 'm2'], { pendingSendIds: ['m2'] }), conversation('c2', ['m1']), conversation('c3', ['m0'])]
const result = withoutConversationMessages(rows, new Set(['m3', 'm1']))
expect(ids(result.rows)).toEqual(['c1', 'c3'])
expect(result.rows[0]?.messageIds).toEqual(['m2'])
expect(result.rows[0]?.pendingSendIds).toEqual(['m2'])
expect(result.removedRows).toBe(1)
expect(result.rows[1]).toBe(rows[2])
})
it('이어서 열 메일 — 메일 목록과 같은 결: 그 줄의 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄 · 앞 줄의 마지막 메일 (리뷰 L3)', () => {
const rows = [conversation('c1', ['m6']), conversation('c2', ['m5', 'm4', 'm3']), conversation('c3', ['m2'])]
// 가운데 메일([이 메일만 열기]로 연 더 오래된 메일) — 다음 칸
expect(conversationNeighborAfterRemoval(rows, 'm4')).toBe('m3')
expect(conversationNeighborAfterRemoval(rows, 'm5')).toBe('m4')
// 줄의 끝 칸 — 앞 칸
expect(conversationNeighborAfterRemoval(rows, 'm3')).toBe('m4')
// 줄에 그 메일뿐 — 다음 줄, 마지막 줄이면 앞 줄의 마지막 메일
expect(conversationNeighborAfterRemoval(rows, 'm6')).toBe('m5')
expect(conversationNeighborAfterRemoval(rows, 'm2')).toBe('m5')
expect(conversationNeighborAfterRemoval([conversation('c1', ['m1'])], 'm1')).toBeNull()
expect(conversationNeighborAfterRemoval(rows, 'zz')).toBeNull()
})
it('이어서 열 메일 — 다음 맞추기 전의 옛 마지막 메일(이미 뺀 메일)이면 남은 메일의 첫 칸', () => {
const stale = conversation('c1', ['m3', 'm2'], { latest: summary('m9') })
expect(conversationNeighborAfterRemoval([stale, conversation('c2', ['m1'])], 'm9')).toBe('m3')
})
})
describe('쪽 붙이기 · 다시 받아 맞추기', () => {
it('붙일 때 이미 있는 줄은 건너뛰고, 다음 쪽의 total 이 없으면 아는 수를 남긴다', () => {
const merged = appendConversationPage([conversation('c1', ['m3'])], INFO({ total: 6 }), {
items: [conversation('c1', ['m3']), conversation('c2', ['m2'])],
pageInfo: INFO({ total: null }),
basis: 'index',
})
expect(ids(merged.rows)).toEqual(['c1', 'c2'])
expect(merged.pageInfo.total).toBe(6)
})
it('다시 받은 범위 밖의 옛 줄은 남기고 옛 커서를 쓴다 — 메일이 겹치는 옛 줄은 같은 대화로 본다(합쳐진 대화)', () => {
const current = [conversation('a', ['m9']), conversation('b', ['m8']), conversation('c', ['m7', 'm6']), conversation('d', ['m5'])]
const fetched = [conversation('x', ['m10']), conversation('a', ['m9']), conversation('b2', ['m8', 'm7'])]
const result = mergeRefreshedConversations({
current,
currentPageInfo: INFO({ hasNext: true, nextCursor: 'old', total: 10 }),
fetched,
fetchedPageInfo: INFO({ hasNext: true, nextCursor: 'new', total: 11 }),
})
// b 는 b2 와 메일이 겹치고(m8) c 는 m7 이 겹쳐 같은 대화로 본다 — 남는 옛 줄은 d
expect(ids(result.rows)).toEqual(['x', 'a', 'b2', 'd'])
expect(result.pageInfo).toEqual({ hasNext: true, nextCursor: 'old', total: 11 })
})
it('"새 메일 N통" — 맨 앞부터 새 줄은 한 통, 새 메일이 든 줄은 새 메일 수를 이어 세고, 바뀌지 않은 줄에서 멈춘다', () => {
const current = [conversation('a', ['m3', 'm2']), conversation('b', ['m1'])]
const fetched = [
conversation('new', ['m9', 'm0']),
conversation('b', ['m8', 'm7', 'm1']),
conversation('a', ['m3', 'm2']),
conversation('late', ['m6']),
]
const result = mergeRefreshedConversations({ current, currentPageInfo: INFO(), fetched, fetchedPageInfo: INFO() })
expect(result.addedAbove).toBe(3)
const unchanged = mergeRefreshedConversations({ current, currentPageInfo: INFO(), fetched: current, fetchedPageInfo: INFO() })
expect(unchanged.addedAbove).toBe(0)
})
})
@@ -0,0 +1,337 @@
// 대화로 묶어 보기 — 묶은 목록(GET /conversations)의 줄을 다루는 순수 함수만 둔다
// (14단계 — 계약 2절 "대화로 묶어 보기" · 디자인 README 「14단계 — 검색 · 대화 묶기」 › "목록 — 대화로 묶어 보기 (ListThreads14)")
// - 응답 맞추기: 모양이 틀린 줄은 뺀다. 마지막 메일(latest)은 늘 messageIds 에 넣어 둔다 — 줄에 든 메일인지 messageIds 만 보면 되게
// - 줄에 하는 일(고르기 · 읽음 · 중요 · 이동 · 삭제)은 messageIds 에서 붙잡힌 메일(pendingSendIds · 마지막 메일의 pendingSend)을 뺀 메일에 한다 —
// 붙잡힌 메일은 취소로만 보낸편지함을 떠난다. 남는 메일이 없으면 그 줄은 고를 수 없다
// - 참여자: 다른 사람은 받은 차례로, 내 주소는 "나"로 끝에 (디자인 원본 ListThreads14 — "이도윤, 나" · "인사팀, 김민수, 나").
// 보낸편지함 · 임시보관함에서 참여자가 나뿐이면 메일 목록처럼 "받는 사람: …" 이다 (나만 늘어선 줄이 되지 않게 — 보드에 없어 정했다)
// - 이 탭이 바꾼 표시(읽음 · 중요): 줄의 메일을 모두 바꿨을 때만 줄 전체가 읽음 · 중요 해제가 된다 — 일부만이면 나머지를 몰라 그대로 두고
// 다음 맞추기(새 메일 확인 · 실시간 소식)가 서버 값으로 고친다. 안 읽음 · 중요는 한 통만 바꿔도 줄이 그렇다.
// 읽음은 붙잡힌 메일(내가 보내는 메일 — 읽은 메일)을 빼고 세지만, 중요 해제는 붙잡힌 메일의 표시를 몰라 그 메일까지 바꿨을 때만이다
// (줄의 중요 표시가 붙잡힌 메일에만 있을 수 있다 — 14단계 리뷰 L1)
// - 뺀 메일: 줄의 메일이 모두 빠지면 줄도 뺀다. 남으면 줄은 그대로다(마지막 메일은 다음 맞추기까지 옛 것 — 부르는 쪽이 곧 다시 맞춘다).
// 뺀 뒤 열 메일은 메일 목록과 같은 결 — 그 줄의 messageIds 에서 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄 · 앞 줄
// - 다시 받아 맞추기: 메일 목록(mail-list-merge.ts)과 같은 규칙 — 다시 받은 범위 밖의 옛 줄은 남긴다. 메일이 겹치는 옛 줄은 같은 대화로 본다
// (늦게 온 원본으로 두 대화가 합쳐지면 id 가 바뀐다 — 계약의 알려진 한계)
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT, MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import type {
Address,
ConversationBasis,
ConversationListPage,
ConversationSummary,
MailboxRole,
MessageSummary,
} from '@/shared/types/mail'
import type { PageInfo } from '@/shared/types/pagination'
import { formatCount } from '@/shared/utils/format'
import { isMyAddress, isSummaryLike, participantName } from '@/shared/utils/mail-conversation'
import { rowWho, type RowWho } from '@/shared/utils/mail-list-row'
import { isHeldMessage } from '@/shared/utils/pending-send'
const CONVERSATION_BASES: readonly string[] = ['index', 'engine']
/** 이 탭이 줄의 메일에 바로 적는 표시 — 메일 목록(list.ts)의 LocalChanges 와 같은 칸 */
export type ConversationFlagChanges = Partial<Pick<MessageSummary, 'isRead' | 'isFlagged' | 'mailboxIds'>>
// ── 응답 맞추기 ─────────────────────────────────────────────
function isAddressLike(value: unknown): value is Address {
if (typeof value !== 'object' || value === null) return false
const candidate = value as { name?: unknown; email?: unknown }
return (
typeof candidate.email === 'string' &&
(candidate.name === null || candidate.name === undefined || typeof candidate.name === 'string')
)
}
function idsOf(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((id): id is string => typeof id === 'string' && id !== '')
}
function booleanOr(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback
}
/** 줄 하나 — 줄이 읽는 칸의 모양이 틀리면 null (그 줄만 뺀다) */
function conversationOf(value: unknown): ConversationSummary | null {
if (typeof value !== 'object' || value === null) return null
const candidate = value as Partial<Record<keyof ConversationSummary, unknown>>
if (typeof candidate.id !== 'string' || candidate.id === '' || !isSummaryLike(candidate.latest)) return null
const latest = candidate.latest
const listed = idsOf(candidate.messageIds)
const messageIds = listed.includes(latest.id) ? listed : [latest.id, ...listed]
const count = candidate.messageCount
return {
id: candidate.id,
latest,
participants: Array.isArray(candidate.participants)
? candidate.participants.filter(isAddressLike).map((address) => ({ name: address.name ?? null, email: address.email }))
: [],
messageCount: typeof count === 'number' && Number.isInteger(count) && count > 0 ? count : messageIds.length,
messageIds,
pendingSendIds: idsOf(candidate.pendingSendIds),
isRead: booleanOr(candidate.isRead, latest.isRead),
isFlagged: booleanOr(candidate.isFlagged, latest.isFlagged),
hasAttachment: booleanOr(candidate.hasAttachment, latest.hasAttachment),
}
}
/**
* GET /conversations 의 응답 → ConversationListPage. 목록 모양이 틀리면 빈 목록, 줄 모양이 틀리면 그 줄만 뺀다.
* 커서가 없는 hasNext 는 다음 쪽이 없는 것으로, 음수 · 정수가 아닌 total 은 모르는 것(null)으로 본다 (같은 대화의 conversationPageOf 와 같다)
*/
export function conversationListPageOf(value: unknown): ConversationListPage {
const empty: ConversationListPage = { items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 }, basis: null }
if (typeof value !== 'object' || value === null) return empty
const candidate = value as { items?: unknown; pageInfo?: unknown; basis?: unknown }
if (!Array.isArray(candidate.items) || typeof candidate.pageInfo !== 'object' || candidate.pageInfo === null) return empty
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.map(conversationOf).filter((row): row is ConversationSummary => row !== null),
pageInfo: { hasNext: info.hasNext === true && nextCursor !== null, nextCursor, total },
basis,
}
}
// ── 줄에 하는 일 ─────────────────────────────────────────────
/** 줄의 붙잡힌 메일 — pendingSendIds 와 마지막 메일의 pendingSend */
function heldIdsOf(row: ConversationSummary): Set<string> {
const held = new Set(row.pendingSendIds)
if (isHeldMessage(row.latest)) held.add(row.latest.id)
return held
}
/** 줄에 하는 일(고르기 · 읽음 · 중요 · 이동 · 삭제)을 보낼 메일 — 붙잡힌 메일은 뺀다 (최신부터) */
export function conversationActionIds(row: ConversationSummary): string[] {
const held = heldIdsOf(row)
return row.messageIds.filter((id) => !held.has(id))
}
/** 줄의 메일이 모두 붙잡힌 메일이다 — 고를 수 없고 별(중요 표시)도 바꿀 수 없다 */
export function isConversationHeld(row: ConversationSummary): boolean {
return conversationActionIds(row).length === 0
}
/** 여러 줄의 할 일 메일 — 겹치지 않게 (같은 id 를 두 번 보내면 서버가 거절한다) */
export function conversationActionIdsOf(rows: readonly ConversationSummary[]): string[] {
return [...new Set(rows.flatMap(conversationActionIds))]
}
/** 그 메일이 든 줄인가 — 마지막 메일은 늘 messageIds 에 있다(conversationListPageOf). 뺀 뒤 남은 옛 마지막 메일은 들지 않는다 */
export function conversationHasMessage(row: ConversationSummary, messageId: string): boolean {
return row.messageIds.includes(messageId)
}
/** 그 메일의 줄 — 줄에 든 메일이거나 그 줄의 마지막 메일(다음 맞추기 전의 옛 것 포함). 없으면 -1 */
export function conversationRowIndexOf(rows: readonly ConversationSummary[], messageId: string): number {
return rows.findIndex((row) => row.latest.id === messageId || conversationHasMessage(row, messageId))
}
// ── 줄의 글 ─────────────────────────────────────────────
/** 참여자 이름 — 다른 사람은 받은 차례(같은 이름은 한 번), 나는 끝에 "나" */
export function conversationParticipantNames(row: ConversationSummary, myEmail: string | null | undefined): string[] {
const others: string[] = []
let mine = false
for (const address of row.participants) {
if (isMyAddress(address, myEmail)) {
mine = true
continue
}
const name = participantName(address, myEmail)
if (name && !others.includes(name)) others.push(name)
}
return mine ? [...others, MAIL_CONVERSATION_TEXT.me] : others
}
export interface ConversationWhoContext {
myEmail: string | null | undefined
/** 목록의 메일함 역할 */
role: MailboxRole | null
view: 'split' | 'table'
}
/**
* 줄의 이름 자리 — 참여자를 쉼표로 ("이도윤, 나"). 보낸편지함 · 임시보관함에서 참여자가 나뿐(또는 없음)이면 마지막 메일의 받는 사람을
* "받는 사람: …" 으로(표 머리는 "보낸 사람"이라 앞말을 붙인다), 그 밖에서 참여자가 없으면 마지막 메일의 보낸 사람 규칙(mail-list-row.ts)
*/
export function conversationWho(row: ConversationSummary, context: ConversationWhoContext): RowWho {
const names = conversationParticipantNames(row, context.myEmail)
const onlyMe = names.length === 0 || (names.length === 1 && names[0] === MAIL_CONVERSATION_TEXT.me)
if (onlyMe && context.role && OUTGOING_MAILBOX_ROLES.includes(context.role)) {
return rowWho(row.latest, { role: context.role, mixed: true, view: context.view })
}
if (names.length === 0) return rowWho(row.latest, { role: context.role, mixed: false, view: context.view })
return { text: names.join(', '), placeholder: false }
}
/** 줄의 통 수 — "4통"(대화 전체 · 보낸편지함 포함), 한 통이면 null (보이지 않는다) */
export function conversationCountText(row: ConversationSummary): string | null {
return row.messageCount > 1 ? MAIL_CONVERSATION_LIST_TEXT.rowCount(formatCount(row.messageCount)) : null
}
/**
* 목록 머리 글 — 대화로 묶었으면 "대화 6개, 안 읽은 메일 2통", 아니면 "메일 9통, 안 읽은 메일 2통".
* 대화 수는 목록의 total — 모르면(null) 그 부분을 빼고, 안 읽은 메일이 없으면 뒤를 뺀다. 둘 다 없으면 빈 글
*/
export function listHeadCountText(input: { grouped: boolean; total: number | null; unread: number }): string {
const parts: string[] = []
if (input.total !== null) {
const count = formatCount(input.total)
parts.push(
input.grouped ? MAIL_CONVERSATION_LIST_TEXT.conversationCount(count) : MAIL_CONVERSATION_LIST_TEXT.messageCount(count),
)
}
if (input.unread > 0) parts.push(MAIL_CONVERSATION_LIST_TEXT.unreadCount(formatCount(input.unread)))
return parts.join(', ')
}
// ── 이 탭이 바꾼 것 ─────────────────────────────────────────────
/**
* 메일들의 표시를 바꾼 뒤의 줄 (머리말) — ids 에 든 메일이 없으면 그 줄 그대로.
* 줄의 메일(붙잡힌 메일은 빼고)을 모두 바꿨으면 읽음 · 중요 해제도 줄 전체에 적는다. 마지막 메일이면 그 메일의 표시 · 메일함도 바꾼다
*/
export function withConversationFlags(
row: ConversationSummary,
ids: ReadonlySet<string>,
changes: ConversationFlagChanges,
): ConversationSummary {
const latestHit = ids.has(row.latest.id)
if (!latestHit && !row.messageIds.some((id) => ids.has(id))) return row
const held = heldIdsOf(row)
// 읽음 — 붙잡힌 메일(내가 보내는 메일 · 읽은 메일)은 빼고 센다. 중요 해제 — 붙잡힌 메일의 표시는 몰라 그 메일까지 바꿨을 때만
const coversRead = row.messageIds.every((id) => ids.has(id) || held.has(id))
const coversAll = row.messageIds.every((id) => ids.has(id))
const next: ConversationSummary = { ...row }
if (latestHit) next.latest = { ...row.latest, ...changes }
if (changes.isRead === false) next.isRead = false
else if (changes.isRead === true && coversRead) next.isRead = true
if (changes.isFlagged === true) next.isFlagged = true
else if (changes.isFlagged === false && coversAll) next.isFlagged = false
return next
}
/** 줄의 읽음 · 중요 표시를 앞 값으로 — 보내지 못한 메일이 든 줄을 되돌린다(여러 통 처리). 마지막 메일이 그대로일 때만 그 메일의 표시도 */
export function withConversationFlagsOf(row: ConversationSummary, original: ConversationSummary): ConversationSummary {
const latest =
row.latest.id === original.latest.id
? { ...row.latest, isRead: original.latest.isRead, isFlagged: original.latest.isFlagged }
: row.latest
return { ...row, isRead: original.isRead, isFlagged: original.isFlagged, latest }
}
/** 메일을 뺀 뒤의 줄 (머리말) — 메일이 모두 빠진 줄은 빼고 그 수를 센다 */
export function withoutConversationMessages(
rows: readonly ConversationSummary[],
ids: ReadonlySet<string>,
): { rows: ConversationSummary[]; removedRows: number } {
let removedRows = 0
const next: ConversationSummary[] = []
for (const row of rows) {
if (!row.messageIds.some((id) => ids.has(id))) {
next.push(row)
continue
}
const messageIds = row.messageIds.filter((id) => !ids.has(id))
if (messageIds.length === 0) {
removedRows += 1
continue
}
next.push({ ...row, messageIds, pendingSendIds: row.pendingSendIds.filter((id) => !ids.has(id)) })
}
return { rows: next, removedRows }
}
/**
* 그 메일을 뺀 뒤 이어서 열 메일 — 메일 목록의 neighborAfterRemoval 과 같은 결이다 (14단계 리뷰 L3).
* 그 줄의 messageIds(최신부터)에서 그 메일의 다음 칸(더 오래된 메일), 없으면 앞 칸. 줄에 그 메일뿐이면 다음 줄의 마지막 메일, 없으면 앞 줄의 마지막 메일.
* 줄의 옛 마지막 메일(다음 맞추기 전 — messageIds 에서 이미 뺐다)이면 남은 메일의 첫 칸. 목록에 없는 메일이면 null
*/
export function conversationNeighborAfterRemoval(rows: readonly ConversationSummary[], messageId: string): string | null {
const index = conversationRowIndexOf(rows, messageId)
if (index === -1) return null
const ids = rows[index]?.messageIds ?? []
const position = ids.indexOf(messageId)
const inRow = position === -1 ? ids[0] : (ids[position + 1] ?? ids[position - 1])
if (inRow !== undefined) return inRow
return rows[index + 1]?.latest.id ?? rows[index - 1]?.latest.id ?? null
}
// ── 쪽 붙이기 · 다시 받아 맞추기 ─────────────────────────────────────────────
/** 받은 쪽을 뒤에 붙인다 — 이미 있는 줄은 건너뛴다. 다음 쪽의 total 이 없으면(색인 방식은 첫 쪽에서만 센다) 아는 수를 남긴다 */
export function appendConversationPage(
rows: readonly ConversationSummary[],
pageInfo: PageInfo | null,
page: ConversationListPage,
): { rows: ConversationSummary[]; pageInfo: PageInfo } {
const known = new Set(rows.map((row) => row.id))
const fresh = page.items.filter((row) => !known.has(row.id))
return {
rows: [...rows, ...fresh],
pageInfo: { ...page.pageInfo, total: page.pageInfo.total ?? pageInfo?.total ?? null },
}
}
export interface ConversationMergeInput {
current: readonly ConversationSummary[]
currentPageInfo: PageInfo | null
fetched: readonly ConversationSummary[]
fetchedPageInfo: PageInfo
}
export interface ConversationMergeResult {
rows: ConversationSummary[]
pageInfo: PageInfo
/** 목록 맨 위의 새 메일 수 — 맨 앞부터 새로 생긴 줄(한 통으로 센다)과 새 메일이 든 줄(새 메일 수)이 이어지는 만큼 ("새 메일 N통") */
addedAbove: number
}
/** 불러온 만큼(최대 100줄)을 다시 받아 맞춘다 (머리말) — 다시 받은 범위는 서버 기준, 그 밖의 옛 줄은 그대로 */
export function mergeRefreshedConversations(input: ConversationMergeInput): ConversationMergeResult {
const { current, fetched } = input
const fetchedIds = new Set(fetched.map((row) => row.id))
const fetchedMessages = new Set(fetched.flatMap((row) => row.messageIds))
const overlaps = (row: ConversationSummary) =>
fetchedIds.has(row.id) || row.messageIds.some((id) => fetchedMessages.has(id))
// 다시 받은 범위와 겹치는 마지막 옛 줄 — 그 뒤의 옛 줄은 다시 받지 않았다
let lastOverlap = -1
current.forEach((row, index) => {
if (overlaps(row)) lastOverlap = index
})
const tail = lastOverlap === -1 ? [] : current.slice(lastOverlap + 1).filter((row) => !overlaps(row))
// 남겨 둔 옛 줄이 있으면 "더 보기"는 그 뒤부터 — 옛 커서를 쓴다. 전체 수는 새 값
const pageInfo =
tail.length > 0 && input.currentPageInfo
? { ...input.currentPageInfo, total: input.fetchedPageInfo.total }
: input.fetchedPageInfo
const previous = new Map(current.map((row) => [row.id, row]))
let addedAbove = 0
for (const row of fetched) {
const before = previous.get(row.id)
if (!before) {
addedAbove += 1
continue
}
const known = new Set(before.messageIds)
const fresh = row.messageIds.filter((id) => !known.has(id)).length
if (fresh === 0) break
addedAbove += fresh
}
return { rows: [...fetched, ...tail], pageInfo, addedAbove }
}
+10 -5
View File
@@ -15,7 +15,7 @@ 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 { mailboxListIdOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
import { isScheduledMessage } from '@/shared/utils/pending-send'
const CONVERSATION_BASES: readonly string[] = ['index', 'engine']
@@ -25,8 +25,11 @@ function emptyConversationPage(): ConversationPage {
return { items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 }, basis: null }
}
/** 줄로 쓸 수 있는 항목인가 — 줄이 읽는 칸(id · 메일함 · 받는 사람 · 제목 · 미리 보기 · 받은 시각)의 모양만 본다 (나머지는 서버의 요약을 믿는다) */
function isSummaryLike(value: unknown): value is MessageSummary {
/**
* 줄로 쓸 수 있는 항목인가 — 줄이 읽는 칸(id · 메일함 · 받는 사람 · 제목 · 미리 보기 · 받은 시각)의 모양만 본다 (나머지는 서버의 요약을 믿는다).
* 대화로 묶은 목록의 마지막 메일(latest)도 이것으로 본다 (mail-conversation-list.ts)
*/
export 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 (
@@ -180,10 +183,12 @@ export function conversationMessageLocation(
): 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)) {
// 메일함 목록(대화로 묶은 목록 포함 — 14단계)은 그 메일함에 든 메일이면 그 목록 안에서 연다
const listMailboxId = mailboxListIdOf(source)
if (source && listMailboxId !== null && message.mailboxIds.includes(listMailboxId)) {
return messageLocationOf(source, message.id)
}
if (source && source.kind !== 'mailbox' && context.listedIds.has(message.id)) return messageLocationOf(source, message.id)
if (source && listMailboxId === null && 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' }
@@ -4,10 +4,12 @@ import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import {
exceedsSearchWords,
isAllMailboxSearch,
isConversationSource,
isSearchRouteName,
listKeyOf,
listLocationOf,
listQueryOf,
mailboxListIdOf,
messageLocationOf,
normalizeSearchText,
sameListSource,
@@ -163,3 +165,29 @@ describe('mail-list-source — 조건 검색 (14단계)', () => {
expect(listKeyOf(plain)).toBe('search:inbox:계약')
})
})
describe('mail-list-source — 대화로 묶은 메일함 (14단계)', () => {
const THREADS: MailListSource = { kind: 'conversations', mailboxId: 'inbox' }
it('주소는 메일함 목록과 같다 — 줄을 누르면 그 메일함 안의 메일 주소', () => {
expect(listLocationOf(THREADS)).toEqual(listLocationOf(INBOX))
expect(messageLocationOf(THREADS, 'm1')).toEqual(messageLocationOf(INBOX, 'm1'))
})
it('가리키는 메일함은 그 메일함 — 메일함 목록처럼 다룬다(같은 대화 칸의 링크 · 다시 받기)', () => {
expect(scopeMailboxIdOf(THREADS)).toBe('inbox')
expect(mailboxListIdOf(THREADS)).toBe('inbox')
expect(mailboxListIdOf(INBOX)).toBe('inbox')
expect(mailboxListIdOf(SEARCH_INBOX)).toBeNull()
expect(isConversationSource(THREADS)).toBe(true)
expect(isConversationSource(INBOX)).toBe(false)
expect(isAllMailboxSearch(THREADS)).toBe(false)
})
it('같은 메일함이어도 묶은 목록과 메일 목록은 다른 목록이다 — 켜고 끄면 고른 것을 풀고 처음 쪽부터 받는다', () => {
expect(sameListSource(THREADS, { kind: 'conversations', mailboxId: 'inbox' })).toBe(true)
expect(sameListSource(THREADS, { kind: 'conversations', mailboxId: 'sent' })).toBe(false)
expect(sameListSource(THREADS, INBOX)).toBe(false)
expect(listKeyOf(THREADS)).not.toBe(listKeyOf(INBOX))
})
})
+32 -8
View File
@@ -1,5 +1,6 @@
// 보이는 메일 목록의 출처 — 메일함 하나 · 검색 결과 · 예약됨(9단계) (순수 함수만 둔다)
// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색" · "보내기 취소 · 예약 발송").
// 보이는 메일 목록의 출처 — 메일함 하나 · 대화로 묶은 메일함(14단계) · 검색 결과 · 예약됨(9단계) (순수 함수만 둔다)
// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색" · "보내기 취소 · 예약 발송" · "대화 — 14단계").
// 대화로 묶은 메일함은 주소가 메일함 목록과 같다 — 묶을지는 사용자 설정(groupByConversation)이 정하고, 줄을 누르면 그 메일함의 마지막 메일을 연다.
import type { LocationQuery, RouteLocationRaw } from 'vue-router'
@@ -41,10 +42,16 @@ export interface ScheduledListSource {
kind: 'scheduled'
}
/** 대화로 묶은 메일함 — GET /conversations?mailboxId= (14단계). 한 줄이 그 메일함의 대화 하나다 */
export interface ConversationListSource {
kind: 'conversations'
mailboxId: string
}
/** GET /messages 로 받는 목록 — 메일함 · 검색 */
export type MessageListSource = MailboxListSource | SearchListSource
export type MailListSource = MessageListSource | ScheduledListSource
export type MailListSource = MessageListSource | ScheduledListSource | ConversationListSource
/** 검색 결과 · 검색 결과에서 연 메일의 라우트 이름 (router/index.ts) */
export const MAIL_SEARCH_ROUTE = 'mail-search'
@@ -67,12 +74,26 @@ export function isScheduledRouteName(name: unknown): boolean {
export function sameListSource(a: MailListSource | null, b: MailListSource | null): boolean {
if (!a || !b) return a === b
if (a.kind === 'mailbox' && b.kind === 'mailbox') return a.mailboxId === b.mailboxId
if (a.kind === 'conversations' && b.kind === 'conversations') return a.mailboxId === b.mailboxId
if (a.kind === 'search' && b.kind === 'search') {
return a.q === b.q && a.scope === b.scope && sameSearchConditions(a.conditions, b.conditions)
}
return a.kind === 'scheduled' && b.kind === 'scheduled'
}
/** 대화로 묶은 메일함인가 (14단계) */
export function isConversationSource(source: MailListSource | null | undefined): source is ConversationListSource {
return source?.kind === 'conversations'
}
/**
* 메일함 하나의 목록(묶었든 아니든)이면 그 메일함 id — 검색 결과 · 예약됨이면 null.
* "그 메일함에 든 메일이면 이 목록 안에서 연다" · "그 메일함을 보고 있으면 다시 받는다"를 가르는 데 쓴다
*/
export function mailboxListIdOf(source: MailListSource | null | undefined): string | null {
return source?.kind === 'mailbox' || source?.kind === 'conversations' ? source.mailboxId : null
}
/** GET /messages 쿼리 — 메일함은 mailboxId, 검색은 q (+ 한 메일함에서 찾으면 mailboxId) + 조건(14단계 — 빈 칸은 넣지 않는다) */
export function listQueryOf(source: MessageListSource): Omit<MessageListQuery, 'cursor' | 'limit'> {
if (source.kind === 'mailbox') return { mailboxId: source.mailboxId }
@@ -83,10 +104,10 @@ export function listQueryOf(source: MessageListSource): Omit<MessageListQuery, '
}
}
/** 출처가 가리키는 메일함 하나 — 메일함 목록이거나 한 메일함 안의 검색. 전체 메일함 검색 · 예약됨이면 null */
/** 출처가 가리키는 메일함 하나 — 메일함 목록(묶었든 아니든)이거나 한 메일함 안의 검색. 전체 메일함 검색 · 예약됨이면 null */
export function scopeMailboxIdOf(source: MailListSource | null): string | null {
if (!source || source.kind === 'scheduled') return null
if (source.kind === 'mailbox') return source.mailboxId
if (source.kind === 'mailbox' || source.kind === 'conversations') return source.mailboxId
return source.scope === SEARCH_SCOPE_ALL ? null : source.scope
}
@@ -104,6 +125,7 @@ export function isScheduledSource(source: MailListSource | null): boolean {
export function listKeyOf(source: MailListSource): string {
if (source.kind === 'scheduled') return 'scheduled'
if (source.kind === 'mailbox') return `mailbox:${source.mailboxId}`
if (source.kind === 'conversations') return `conversations:${source.mailboxId}`
const conditions = searchConditionsKey(source.conditions)
return conditions ? `search:${source.scope}:${source.q}:${conditions}` : `search:${source.scope}:${source.q}`
}
@@ -127,16 +149,18 @@ export function searchSourceOf(q: string, scope: string, conditions?: SearchCond
: { kind: 'search', q, scope }
}
/** 목록 화면 주소 */
/** 목록 화면 주소 — 대화로 묶은 메일함도 메일함 주소다 */
export function listLocationOf(source: MailListSource): RouteLocationRaw {
if (source.kind === 'mailbox') return { name: 'mail-mailbox', params: { mailboxId: source.mailboxId } }
if (source.kind === 'mailbox' || source.kind === 'conversations') {
return { name: 'mail-mailbox', params: { mailboxId: source.mailboxId } }
}
if (source.kind === 'scheduled') return { name: MAIL_SCHEDULED_ROUTE }
return { name: MAIL_SEARCH_ROUTE, query: searchRouteQuery(source) }
}
/** 목록에서 연 메일의 주소 — 검색 결과에서 열면 /mail/search/<메일 id>?q=…&in=…, 예약됨은 /mail/scheduled/<메일 id> */
export function messageLocationOf(source: MailListSource, messageId: string): RouteLocationRaw {
if (source.kind === 'mailbox') {
if (source.kind === 'mailbox' || source.kind === 'conversations') {
return { name: 'mail-message', params: { mailboxId: source.mailboxId, messageId } }
}
if (source.kind === 'scheduled') return { name: MAIL_SCHEDULED_MESSAGE_ROUTE, params: { messageId } }
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailSettings } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { groupByConversationHintKey, useMailSettingsStore } from './mail-settings.store'
// 보내기 · 읽기 설정의 묶어 보기 (14단계) — 설정을 받기 전의 짐작(사용자마다 · localStorage), 로그아웃이 보이는 값을 바꾸지 않음(리뷰 M1),
// 받지 못했을 때도 고르기(리뷰 L4)
const ARA = { email: 'ara.kim@arasystem.test', name: '김아라' }
const DOYUN = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
const SETTINGS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
describe('mail-settings.store — 묶어 보기 짐작 · 받지 못했을 때', () => {
let requests: MockRequest[]
let load: () => MockResult
let patch: (request: MockRequest) => MockResult
beforeEach(() => {
window.localStorage.clear()
setActivePinia(createPinia())
load = () => ok(SETTINGS)
patch = (request) => ok({ ...SETTINGS, ...(request.data as Partial<MailSettings>) })
requests = installMockApi((request) => (request.method === 'GET' ? load() : patch(request)))
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
})
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
it('짐작은 사용자마다다 — 김아라가 켰어도 이도윤은 묶지 않는다(처음 값)', async () => {
window.localStorage.setItem(groupByConversationHintKey(ARA.email), 'true')
const auth = useAuthStore()
const store = useMailSettingsStore()
auth.markAuthenticated(ARA)
await flushPromises()
expect(store.groupByConversation).toBe(true)
auth.clearAuth()
await flushPromises()
auth.markAuthenticated(DOYUN)
await flushPromises()
expect(store.groupByConversation).toBe(false)
})
it('받으면 서버의 값이 이기고 그 사용자의 짐작으로 기억한다', async () => {
useAuthStore().markAuthenticated(DOYUN)
const store = useMailSettingsStore()
expect(store.groupByConversation).toBe(false)
await store.load()
expect(store.groupByConversation).toBe(true)
expect(window.localStorage.getItem(groupByConversationHintKey(DOYUN.email))).toBe('true')
})
it('로그아웃 · 세션 만료는 보이는 값을 바꾸지 않는다 — 떠나는 메일 화면이 목록을 다시 부르지 않게 (리뷰 M1)', async () => {
const auth = useAuthStore()
auth.markAuthenticated(ARA)
const store = useMailSettingsStore()
await store.load()
expect(store.groupByConversation).toBe(true)
auth.clearAuth()
await flushPromises()
expect(store.settings).toBeNull()
expect(store.groupByConversation).toBe(true)
auth.markAuthenticated(ARA)
await store.load()
auth.expireSession()
await flushPromises()
expect(store.groupByConversation).toBe(true)
})
it('localStorage 를 읽지 · 쓰지 못해도 던지지 않는다 — 짐작은 처음 값(묶지 않음), 받은 값은 그대로 쓴다', async () => {
window.localStorage.setItem(groupByConversationHintKey(ARA.email), 'true')
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked')
})
useAuthStore().markAuthenticated(ARA)
const store = useMailSettingsStore()
expect(store.groupByConversation).toBe(false)
await store.load()
expect(store.groupByConversation).toBe(true)
})
describe('받지 못했으면 짐작대로 두고 [대화로 묶기]는 고를 수 있다 (리뷰 L4)', () => {
beforeEach(() => {
window.localStorage.setItem(groupByConversationHintKey(ARA.email), 'true')
useAuthStore().markAuthenticated(ARA)
load = () => fail(503, 'SYS_001')
})
it('그 칸만 PATCH 하고, 응답(설정 전체)을 받은 설정으로 삼는다', async () => {
const store = useMailSettingsStore()
await store.load()
expect([store.status, store.settings, store.groupByConversation]).toEqual(['error', null, true])
const saving = store.update('groupByConversation', false)
expect(store.groupByConversation).toBe(false)
expect(await saving).toBe(true)
expect(patches()).toEqual([{ groupByConversation: false }])
expect(store.status).toBe('ready')
expect(store.settings).toEqual({ ...SETTINGS, groupByConversation: false })
expect(store.groupByConversation).toBe(false)
expect(window.localStorage.getItem(groupByConversationHintKey(ARA.email))).toBe('false')
expect(store.saved).toBe(true)
})
it('저장도 실패하면 고르기 전의 짐작으로 되돌린다 — 알림은 인터셉터', async () => {
patch = () => fail(400, 'VAL_001')
const store = useMailSettingsStore()
await store.load()
expect(await store.update('groupByConversation', false)).toBe(false)
expect(store.groupByConversation).toBe(true)
expect(store.status).toBe('error')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
})
it('받은 값이 없으면 다른 칸(보내기 취소)은 고르지 않는다 — 받는 중에도 고르지 않는다', async () => {
const store = useMailSettingsStore()
await store.load()
expect(await store.update('undoSendSeconds', 5)).toBe(false)
load = () => ok(SETTINGS)
const loading = store.load()
expect(await store.update('groupByConversation', false)).toBe(false)
await loading
expect(patches()).toEqual([])
})
})
})
+120 -10
View File
@@ -9,9 +9,40 @@ import type { LoadStatus } from '@/stores/mail.store'
type SettingKey = keyof MailSettings
const GROUP_HINT_KEY_PREFIX = 'arasystem.mail.groupByConversation:'
/** 묶어 보기의 짐작을 기억하는 localStorage 키 — 사용자 주소마다 따로 (보기 방식 mail-view.store 와 같은 결) */
export function groupByConversationHintKey(email: string): string {
return `${GROUP_HINT_KEY_PREFIX}${email.trim().toLowerCase()}`
}
// 사생활 보호 모드 · 저장소 차단 등으로 localStorage 가 실패해도 짐작 없이(묶지 않음) 동작한다
function readGroupHint(email: string | null): boolean {
if (!email) return false
try {
return window.localStorage.getItem(groupByConversationHintKey(email)) === 'true'
} catch {
return false
}
}
function writeGroupHint(email: string, grouped: boolean) {
try {
window.localStorage.setItem(groupByConversationHintKey(email), String(grouped))
} catch {
// 기억하지 못해도 이번 화면은 서버의 값으로 동작한다
}
}
// 보내기 · 읽기 설정 스토어 — 계약 2절 "보내기 · 읽기 설정" (9단계 — 직원마다 서버 DB 에 하나, 어느 PC 에서나 같다)
// - 설정 › 메일 › 보내기 · 읽기는 들어올 때마다 새로 받는다(load) — 다른 PC 에서 바꿨을 수 있다. 오류는 화면이 직접 그린다.
// 메일 화면은 들어올 때 한 번 받아 둔다(ensureLoaded — 알리지 않는다): 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다
// 메일 화면은 들어올 때 한 번 받아 둔다(ensureLoaded — 알리지 않는다): 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 세고,
// 메일함 화면이 대화로 묶어 볼지(groupByConversation — 14단계) 정한다. 목록 머리의 [대화로 묶기]도 이 스토어의 update 로 저장한다.
// 받기 전에는 이 브라우저가 마지막으로 안 값(localStorage — 사용자 주소마다)으로 짐작한다: 새로고침마다 메일 목록을 받았다가 버리고
// 묶은 목록을 다시 받지 않게(엔진 조회 한 번을 아낀다). 받으면 서버의 값이 이기고 짐작을 고친다.
// 받지 못했으면(error) 짐작대로 두고, 그때도 [대화로 묶기]는 고를 수 있다 — 그 칸만 PATCH 하고, 응답(설정 전체)을 받은 설정으로 삼는다.
// 실패하면 고르기 전의 짐작으로 되돌린다. 로그아웃 · 세션 만료(reset)는 화면이 보는 값을 바꾸지 않는다 — 떠나는 메일 화면이 목록을 다시 부르지 않게
// (2026-11-06 리뷰 M1). 다른 사람이 로그인하면 그 사람의 짐작으로 바뀐다
// - 고르는 대로 그 칸만 PATCH 한다(저장 단추 없음). 화면은 늘 마지막으로 고른 값을 보인다.
// **칸마다 저장은 한 번에 하나**(6단계 필터의 saving 과 같은 결) — 보내는 동안 다시 고르면 마지막으로 고른 것만 기억해 두고,
// 앞 저장이 끝난 뒤 그것이 서버의 답과 다르면 보낸다. 두 저장을 함께 보내면 서버가 나중에 처리한 쪽이 남는데, 응답 차례로는 그것을 알 수 없어
@@ -31,6 +62,10 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
const saved = ref<boolean>(false)
/** 저장하는 중인 칸 수 */
const pending = ref<number>(0)
/** 설정을 받기 전의 묶어 보기 짐작 — 이 브라우저에서 이 사용자가 마지막으로 쓴 값 (위 머리말) */
const groupHint = ref<boolean>(false)
/** 받지 못한 채(error) 고른 묶어 보기의 고르기 전 짐작 — 저장이 실패하면 이 값으로 되돌린다. 고른 저장이 없으면 null */
let hintBeforeChoice: boolean | null = null
/**
* 알고 있는 보내기 취소 시간(초) — 받아 둔 설정이 있을 때만, 모르면 null.
@@ -40,6 +75,14 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
status.value === 'ready' && settings.value ? settings.value.undoSendSeconds : null,
)
/**
* 메일함 목록을 대화로 묶어 보기 (14단계) — 받아 둔 설정의 값, 받기 전 · 받지 못했으면 짐작(이 브라우저가 이 사용자에게서 마지막으로 안 값,
* 처음이면 묶지 않음). 고르면 저장이 끝나기 전에 먼저 바뀐다(위 settings 와 같다) — 메일함 화면이 이 값으로 목록 출처를 정한다 (MailboxPage)
*/
const groupByConversation = computed<boolean>(() =>
settings.value ? settings.value.groupByConversation === true : groupHint.value,
)
/** 서버가 마지막으로 확인해 준 값 — 마지막으로 고른 것의 저장이 실패하면 그 칸을 이 값으로 되돌린다 */
let confirmed: MailSettings | null = null
/** 칸마다 마지막으로 고른 값 — 보내는 저장이 끝나면 이것을 본다 (보낼 것이 남았는지) */
@@ -67,6 +110,9 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
saved.value = false
// 저장하던 중에 떠났다 돌아왔으면 그 저장은 이 화면의 것이 아니다 — 세던 수가 남으면 "저장했습니다"가 다시 뜨지 않는다 (리뷰 #7)
forgetSaves()
// 받지 못한 채 고른 묶어 보기는 저장됐는지 모른다 — 짐작을 고르기 전으로 (받으면 서버의 값이 이긴다)
if (hintBeforeChoice !== null) groupHint.value = hintBeforeChoice
hintBeforeChoice = null
try {
const result = await settingsApi.getSettings({ silent: true })
if (current !== loadSeq) return settings.value
@@ -87,8 +133,28 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
if (status.value === 'idle' || status.value === 'error') void load()
}
/** 고를 수 있는가 — 받는 중 · 받기 전에는 못 한다. 받지 못했으면(error) 받아 둔 값이 있을 때, 없으면 짐작으로 보이는 묶어 보기만 */
function canChoose(key: SettingKey): boolean {
if (status.value === 'ready') return settings.value !== null
if (status.value !== 'error') return false
return settings.value !== null || key === 'groupByConversation'
}
/** 화면이 보이는 값 — 받아 둔 설정, 없으면(받지 못함) 묶어 보기는 짐작 */
function shownOf<K extends SettingKey>(key: K): MailSettings[K] | undefined {
if (settings.value) return settings.value[key]
return key === 'groupByConversation' ? (groupHint.value as MailSettings[K]) : undefined
}
function setKey<K extends SettingKey>(key: K, value: MailSettings[K]) {
if (settings.value) settings.value = { ...settings.value, [key]: value }
else if (key === 'groupByConversation') groupHint.value = value as boolean
}
/** 저장이 실패하면 되돌릴 값 — 서버가 마지막으로 확인해 준 값, 받지 못한 채 고른 묶어 보기는 고르기 전의 짐작 */
function fallbackOf<K extends SettingKey>(key: K): MailSettings[K] | undefined {
if (confirmed) return confirmed[key]
return key === 'groupByConversation' && hintBeforeChoice !== null ? (hintBeforeChoice as MailSettings[K]) : undefined
}
/** 그 칸의 저장을 보낸다 — 끝나면 마지막으로 고른 값이 서버의 답과 다를 때만 그것을 이어 보낸다. 마지막 요청이 성공했으면 true */
@@ -106,8 +172,15 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
const result = await settingsApi.updateSettings({ [key]: value } as Partial<MailSettings>)
if (!stillCurrent()) return false
succeeded = true
confirmed = { ...(confirmed ?? result), [key]: result[key] }
const latest = wanted.get(key) as MailSettings[K]
// 받지 못한 채 고른 것이었으면 응답(저장한 뒤의 설정 전체)을 받은 설정으로 삼는다 — 고른 값은 그대로 보인다
if (!settings.value) {
settings.value = { ...result, [key]: latest }
status.value = 'ready'
error.value = null
}
hintBeforeChoice = null
confirmed = { ...(confirmed ?? result), [key]: result[key] }
// 그사이 다른 값을 골랐고 그것이 서버의 답과 다르면 이어 보낸다 — 아니면 서버의 답으로 맞추고 끝낸다
if (latest !== value && latest !== result[key]) next = latest
else setKey(key, result[key])
@@ -115,9 +188,10 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
if (!stillCurrent()) return false
succeeded = false
const latest = wanted.get(key) as MailSettings[K]
// 그사이 다른 값을 골랐으면(서버가 확인한 값이 아니면) 그것을 보내 본다 — 아니면 서버가 확인한 값으로 되돌린다
if (latest !== value && latest !== confirmed?.[key]) next = latest
else if (confirmed) setKey(key, confirmed[key])
const fallback = fallbackOf(key)
// 그사이 다른 값을 골랐으면(서버가 확인한 값이 아니면) 그것을 보내 본다 — 아니면 서버가 확인한 값(받지 못했으면 고르기 전 짐작)으로 되돌린다
if (latest !== value && latest !== fallback) next = latest
else if (fallback !== undefined) setKey(key, fallback)
}
if (next === undefined) break
value = next
@@ -127,6 +201,7 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
if (stillCurrent()) {
wanted.delete(key)
chains.delete(key)
if (key === 'groupByConversation') hintBeforeChoice = null
lastSucceeded = succeeded
pending.value = Math.max(0, pending.value - 1)
if (pending.value === 0) saved.value = lastSucceeded
@@ -136,14 +211,15 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
/**
* 한 칸을 고른다 — 화면을 먼저 바꾸고 PATCH { key: value }. 그 칸의 저장이 이미 돌고 있으면 보내지 않고 고른 값만 기억한다
* (앞 저장이 끝나면 이어 보낸다 — 위 머리말). 받은 설정이 없으면 보내지 않는다(false).
* 돌려주는 약속은 그 칸의 저장이 다 끝났을 때 — 마지막 요청이 성공했으면 true
* (앞 저장이 끝나면 이어 보낸다 — 위 머리말). 받는 중 · 받기 전이면 보내지 않는다(false). 받지 못했으면(error) 받아 둔 값이 없어도
* 묶어 보기는 짐작 위에서 고른다(canChoose). 돌려주는 약속은 그 칸의 저장이 다 끝났을 때 — 마지막 요청이 성공했으면 true
*/
function update<K extends SettingKey>(key: K, value: MailSettings[K]): Promise<boolean> {
if (!settings.value || status.value !== 'ready') return Promise.resolve(false)
if (!canChoose(key)) return Promise.resolve(false)
const running = chains.get(key)
// 지금 값을 다시 눌렀다 — 보낼 것이 없다
if (!running && settings.value[key] === value) return Promise.resolve(true)
if (!running && shownOf(key) === value) return Promise.resolve(true)
if (!running && !settings.value && key === 'groupByConversation') hintBeforeChoice = groupHint.value
setKey(key, value)
wanted.set(key, value)
saved.value = false
@@ -155,6 +231,9 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
function reset() {
loadSeq += 1
// 화면이 보는 묶어 보기는 그대로 둔다 — 로그아웃 · 세션 만료 직후 떠나는 메일 화면이 다른 목록을 부르지 않게 (리뷰 M1)
groupHint.value = groupByConversation.value
hintBeforeChoice = null
settings.value = null
confirmed = null
forgetSaves()
@@ -169,5 +248,36 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
() => reset(),
)
return { settings, status, error, saved, pending, knownUndoSendSeconds, load, ensureLoaded, update, reset }
// 묶어 보기의 짐작 — 로그인한 사용자의 것을 읽고(로그아웃은 그대로 둔다 — reset), 받거나 고른 값이 생기면 기억해 둔다
watch(
() => authStore.user?.email ?? null,
(email) => {
if (email) groupHint.value = readGroupHint(email)
},
{ immediate: true },
)
watch(
() => settings.value?.groupByConversation,
(grouped) => {
const email = authStore.user?.email
if (email && grouped !== undefined) {
groupHint.value = grouped
writeGroupHint(email, grouped)
}
},
)
return {
settings,
status,
error,
saved,
pending,
knownUndoSendSeconds,
groupByConversation,
load,
ensureLoaded,
update,
reset,
}
})
@@ -0,0 +1,541 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { ConversationSummary, Mailbox, MessageSummary, MessagesPatch } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useMailStore } from './mail.store'
// 14단계 3차 — 목록을 대화로 묶어 보기 (계약 2절 "대화로 묶어 보기"): 출처 · 늦은 응답 · 더 보기 · 맞추기 · 고르기 · 여러 통 처리
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
function mailbox(id: string, role: Mailbox['role'], name: string, unreadCount = 0): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 10, unreadCount }
}
const MAILBOXES = [
mailbox('inbox', 'inbox', '받은편지함', 2),
mailbox('sent', 'sent', '보낸편지함'),
mailbox('archive', 'archive', '보관함'),
mailbox('trash', 'trash', '휴지통'),
]
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-11-05T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? id),
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
messageCount: messageIds.length,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
function threadsPage(items: ConversationSummary[], nextCursor: string | null = null, total: number | null = items.length) {
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
}
function messagesPage(items: MessageSummary[]) {
return ok({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
}
/** 응답을 테스트가 정할 때까지 붙잡아 둔다 */
function held() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((done) => {
resolve = done
})
return { promise, resolve }
}
const rowIds = (rows: readonly { id: string }[]) => rows.map((row) => row.id)
const threadRequests = (requests: MockRequest[]) => requests.filter((request) => request.url === '/conversations')
const patches = (requests: MockRequest[]) =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data as MessagesPatch)
describe('mail.store — 대화로 묶어 보기 (14단계)', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
/** 메일함 목록과 묶은 목록의 첫 쪽을 받은 상태로 — handler 가 undefined 를 주면 기본 응답 */
async function openThreads(
rows: ConversationSummary[],
handler?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined,
) {
const requests = installMockApi((request) => {
const custom = handler?.(request)
if (custom) return custom
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 4 } })
if (request.url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
if (request.url === '/conversations') return threadsPage(rows)
if (request.url === '/messages' && request.method === 'GET') return messagesPage([])
if (request.url === '/message-deletions') return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
if (request.url === '/messages' && request.method === 'PATCH') {
return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
}
return ok(null)
})
const store = useMailStore()
await store.loadMailboxes()
await store.openConversations('inbox')
return { store, requests }
}
describe('출처 · 늦은 응답', () => {
it('켜면 GET /conversations?mailboxId= 의 첫 쪽 — 한 줄이 대화 하나, 메일 목록은 비어 있고 가리키는 메일함은 그 메일함', async () => {
const { store, requests } = await openThreads([conversation('c1', ['m3', 'm2']), conversation('c2', ['m1'])])
expect(threadRequests(requests).map((request) => request.params)).toEqual([{ mailboxId: 'inbox', limit: 20 }])
expect(threadRequests(requests)[0]?.config.silent).toBe(true)
expect(store.isConversationList).toBe(true)
expect(rowIds(store.conversations)).toEqual(['c1', 'c2'])
expect(store.messages).toEqual([])
expect(store.rowCount).toBe(2)
expect(store.currentMailboxId).toBe('inbox')
expect(store.listedSummaries.map((message) => message.id)).toEqual(['m3', 'm1'])
expect(store.pageInfo?.total).toBe(2)
})
it('켜고 끌 때 앞 목록의 응답을 버리고 처음 쪽부터 받는다 — 늦게 온 메일 목록이 묶은 목록을 덮지 않는다', async () => {
const messages = held()
const { store, requests } = await openThreads([conversation('c1', ['m1'])], (request) =>
request.url === '/messages' && request.method === 'GET' ? messages.promise : undefined,
)
const opening = store.openMailbox('inbox')
expect(store.listStatus).toBe('loading')
expect(store.conversations).toEqual([])
const reopened = store.openConversations('inbox')
messages.resolve(messagesPage([summary('late')]))
await Promise.all([opening, reopened])
expect(store.messages).toEqual([])
expect(rowIds(store.conversations)).toEqual(['c1'])
expect(threadRequests(requests)).toHaveLength(2)
})
it('끄면 메일 목록으로 — 보이는 채로 새로 고치던 묶은 목록의 늦은 응답은 버린다', async () => {
const threads = held()
let calls = 0
const { store } = await openThreads([conversation('c1', ['m1'])], (request) => {
if (request.url !== '/conversations') return undefined
calls += 1
return calls === 1 ? undefined : threads.promise
})
const refreshing = store.openConversations('inbox')
const switched = store.openMailbox('inbox')
threads.resolve(threadsPage([conversation('late', ['m9'])]))
await Promise.all([refreshing, switched])
expect(store.isConversationList).toBe(false)
expect(store.conversations).toEqual([])
})
it('버린 목록 요청은 끊는다 — 켜고 끌 때 · 메일함을 바꿀 때 · 세션이 끝날 때 (리뷰 L7)', async () => {
const messages = held()
const threads = held()
const { store, requests } = await openThreads([], (request) => {
if (request.url === '/messages' && request.method === 'GET') return messages.promise
if (request.url === '/conversations' && request.params.mailboxId === 'archive') return threads.promise
return undefined
})
const signals = (url: string) => requests.filter((request) => request.url === url).map((request) => request.config.signal)
// 요청이 나간 뒤 바꾼다 (나가기 전에 바꾸면 보내지도 않는다)
void store.openMailbox('inbox')
await flushPromises()
void store.openConversations('archive')
await flushPromises()
expect(signals('/messages').at(-1)?.aborted).toBe(true)
expect(signals('/conversations').at(-1)?.aborted).toBe(false)
useAuthStore().clearAuth()
await flushPromises()
expect(signals('/conversations').at(-1)?.aborted).toBe(true)
messages.resolve(messagesPage([summary('late')]))
threads.resolve(threadsPage([conversation('late', ['m9'])]))
await flushPromises()
expect(store.messages).toEqual([])
expect(store.conversations).toEqual([])
})
it('세션이 바뀌면(다른 계정) 늦게 온 묶은 목록을 버린다', async () => {
const threads = held()
const { store } = await openThreads([], (request) =>
request.url === '/conversations' && request.params.mailboxId === 'archive' ? threads.promise : undefined,
)
void store.openConversations('archive')
useAuthStore().markAuthenticated({ email: 'other@arasystem.test', name: '다른 사람' })
await flushPromises()
threads.resolve(threadsPage([conversation('late', ['m9'])]))
await flushPromises()
expect(store.conversations).toEqual([])
expect(store.listSource).toBeNull()
})
})
describe('더 보기 · 맞추기', () => {
it('더 보기 — 커서를 그대로 보내고 이미 있는 줄은 건너뛴다. 다음 쪽의 total 이 없으면 첫 쪽의 수를 남긴다', async () => {
const { store, requests } = await openThreads([], (request) => {
if (request.url !== '/conversations') return undefined
return request.params.cursor === 'p2'
? threadsPage([conversation('c2', ['m2']), conversation('c3', ['m1'])], null, null)
: threadsPage([conversation('c1', ['m3']), conversation('c2', ['m2'])], 'p2', 6)
})
await store.openConversations('sent')
expect(store.hasMore).toBe(true)
await store.loadMore()
expect(threadRequests(requests).at(-1)?.params).toEqual({ mailboxId: 'sent', cursor: 'p2', limit: 20 })
expect(rowIds(store.conversations)).toEqual(['c1', 'c2', 'c3'])
expect(store.pageInfo?.total).toBe(6)
expect(store.hasMore).toBe(false)
})
it('새 메일 확인 — 불러온 줄 수만큼(최대 100줄) 조용히 다시 받고, 맨 위 새 메일 수를 알린다', async () => {
let rows = [conversation('c1', ['m2']), conversation('c2', ['m1'])]
const { store, requests } = await openThreads([], (request) => (request.url === '/conversations' ? threadsPage(rows) : undefined))
await store.openConversations('sent')
rows = [conversation('c2', ['m5', 'm4', 'm1']), conversation('c1', ['m2'])]
await store.syncVisibleList()
const last = threadRequests(requests).at(-1)
expect(last?.params).toEqual({ mailboxId: 'sent', limit: 20 })
expect(last?.config.silent).toBe(true)
expect(rowIds(store.conversations)).toEqual(['c2', 'c1'])
expect(store.listSync?.addedAbove).toBe(2)
})
it('맞추는 사이 이 탭이 줄을 바꿨으면 그 응답은 버리고 한 번 더 맞춘다 — 보내는 중인 읽음이 옛 값을 덮어쓴다', async () => {
const sync = held()
const patch = held()
let calls = 0
const unread = () => threadsPage([conversation('c1', ['m2', 'm1'], { isRead: false })])
const { store } = await openThreads([], (request) => {
if (request.url === '/conversations') {
calls += 1
return calls === 2 ? sync.promise : unread()
}
if (request.url === '/messages' && request.method === 'PATCH') return patch.promise
return undefined
})
expect(store.conversations[0]?.isRead).toBe(false)
const syncing = store.syncVisibleList()
store.toggleCheck('c1')
const marking = store.patchChecked({ isRead: true })
expect(store.conversations[0]?.isRead).toBe(true)
// 서버는 아직 옛 값(안 읽음)을 준다 — 이 탭이 바꾼 뒤라 버리고 한 번 더 받는다
sync.resolve(unread())
await syncing
expect(calls).toBe(3)
expect(store.conversations[0]?.isRead).toBe(true)
patch.resolve(ok({ updatedIds: ['m2', 'm1'], failedIds: [] }))
await marking
expect(store.conversations[0]?.isRead).toBe(true)
})
})
describe('고르기 — 붙잡힌 메일은 뺀다', () => {
const ROWS = [
conversation('c1', ['m4', 'm3'], { pendingSendIds: ['m4'] }),
conversation('held', ['m2'], { pendingSendIds: ['m2'] }),
conversation('c3', ['m1']),
]
it('메일이 모두 붙잡힌 줄은 고를 수 없다 — 하나 · 모두 고르기가 건너뛴다', async () => {
const { store } = await openThreads(ROWS)
store.toggleCheck('held')
expect(store.checkedCount).toBe(0)
store.toggleCheckAll()
expect([...store.checkedIds]).toEqual(['c1', 'c3'])
expect(store.checkState).toBe('all')
})
it('여러 통 처리가 보낼 메일은 고른 줄의 메일에서 붙잡힌 메일을 뺀 것 — 도구 줄의 수는 줄 수다', async () => {
const { store } = await openThreads(ROWS)
store.toggleCheckAll()
expect(store.checkedCount).toBe(2)
expect(store.checkedActionIds).toEqual(['m3', 'm1'])
expect(store.checkedMessages).toEqual([])
expect(rowIds(store.checkedConversations)).toEqual(['c1', 'c3'])
})
it('켜고 끄면 고른 것을 푼다', async () => {
const { store } = await openThreads(ROWS)
store.toggleCheck('c3')
await store.openMailbox('inbox')
expect(store.checkedCount).toBe(0)
})
})
describe('여러 통 처리', () => {
it('읽음 표시 — 고른 줄의 메일을 100통씩 PATCH /messages, 줄을 먼저 바꾸고 끝나면 메일함 개수를 다시 받는다', async () => {
const many = Array.from({ length: 150 }, (_, index) => `m${index}`)
const { store, requests } = await openThreads([conversation('big', many, { isRead: false })])
const mailboxLoads = requests.filter((request) => request.url === '/mailboxes').length
store.toggleCheck('big')
const marking = store.patchChecked({ isRead: true })
expect(store.conversations[0]?.isRead).toBe(true)
await marking
await flushPromises()
expect(patches(requests).map((patch) => [patch.ids.length, patch.isRead])).toEqual([
[100, true],
[50, true],
])
expect(patches(requests).flatMap((patch) => patch.ids)).toEqual(many)
expect(requests.filter((request) => request.url === '/mailboxes').length).toBe(mailboxLoads + 1)
// 고른 것은 그대로 둔다
expect(store.checkedCount).toBe(1)
})
it('서버가 바꾸지 못한 메일이 든 줄은 앞 값으로 되돌리고 목록을 다시 맞춘다', async () => {
const sync = held()
let calls = 0
const { store, requests } = await openThreads(
[conversation('c1', ['m2', 'm1'], { isRead: false }), conversation('c2', ['m3'], { isRead: false })],
(request) => {
if (request.url === '/conversations' && ++calls > 1) return sync.promise
return request.url === '/messages' && request.method === 'PATCH'
? ok({ updatedIds: ['m2', 'm3'], failedIds: ['m1'] })
: undefined
},
)
store.toggleCheckAll()
await store.patchChecked({ isRead: true })
await flushPromises()
// 이 탭이 먼저 되돌린다 — m1 을 바꾸지 못한 c1 만
expect(store.conversations.map((row) => [row.id, row.isRead])).toEqual([
['c1', false],
['c2', true],
])
// 그리고 서버 기준으로 다시 맞춘다
expect(threadRequests(requests)).toHaveLength(2)
sync.resolve(threadsPage([conversation('c1', ['m2', 'm1'], { isRead: false }), conversation('c2', ['m3'])]))
await flushPromises()
expect(store.conversations.map((row) => [row.id, row.isRead])).toEqual([
['c1', false],
['c2', true],
])
})
it('옮기기 — 고른 줄의 메일을 보내고 다 옮긴 줄은 빼며 대화 수를 줄인다. 되돌리기는 그 메일함으로, 끝나면 목록을 다시 맞춘다', async () => {
const sync = held()
let calls = 0
const { store, requests } = await openThreads(
[conversation('c1', ['m4', 'm3'], { pendingSendIds: ['m4'] }), conversation('c2', ['m2', 'm1'])],
(request) => (request.url === '/conversations' && ++calls > 1 ? sync.promise : undefined),
)
store.toggleCheckAll()
const result = await store.moveChecked('archive')
await flushPromises()
expect(patches(requests)).toEqual([{ ids: ['m3', 'm2', 'm1'], mailboxId: 'archive' }])
// 붙잡힌 메일(m4)이 남은 줄은 남는다
expect(rowIds(store.conversations)).toEqual(['c1'])
expect(store.conversations[0]?.messageIds).toEqual(['m4'])
expect(store.pageInfo?.total).toBe(1)
expect(result.origins).toEqual([{ mailboxId: 'inbox', ids: ['m3', 'm2', 'm1'] }])
expect(store.checkedCount).toBe(0)
// 남은 줄의 마지막 메일 · 통 수를 서버 값으로 — 목록을 다시 맞춘다
expect(threadRequests(requests)).toHaveLength(2)
sync.resolve(threadsPage([conversation('c1', ['m4'], { pendingSendIds: ['m4'], messageCount: 2 })]))
await flushPromises()
expect(store.conversations.map((row) => [row.id, row.messageCount])).toEqual([['c1', 2]])
})
it('영구 삭제 — 고른 줄의 메일을 POST /message-deletions', async () => {
const { store, requests } = await openThreads([conversation('c1', ['m2', 'm1']), conversation('c2', ['m0'])])
store.toggleCheck('c1')
await store.purgeChecked()
expect(requests.filter((request) => request.url === '/message-deletions').map((request) => request.data)).toEqual([
{ ids: ['m2', 'm1'] },
])
expect(rowIds(store.conversations)).toEqual(['c2'])
})
it('줄의 별 — 중요 표시한 메일이 있으면 모두 풀고 없으면 모두 표시한다. 메일이 모두 붙잡힌 줄은 보내지 않는다', async () => {
const { store, requests } = await openThreads([
conversation('c1', ['m3', 'm2'], { pendingSendIds: ['m3'] }),
conversation('c2', ['m1'], { isFlagged: true }),
conversation('held', ['m0'], { pendingSendIds: ['m0'] }),
])
await store.toggleConversationFlag('c1')
await store.toggleConversationFlag('c2')
await store.toggleConversationFlag('held')
expect(patches(requests)).toEqual([
{ ids: ['m2'], isFlagged: true },
{ ids: ['m1'], isFlagged: false },
])
expect(store.conversations.map((row) => row.isFlagged)).toEqual([true, false, false])
})
})
describe('별 — 줄의 중요 표시가 붙잡힌 메일에만 있을 때 (리뷰 L1)', () => {
it('붙잡힌 메일이 든 줄을 풀면 줄을 미리 풀지 않고 서버 기준으로 한 번 맞춘다 — 그래도 중요 표시면 heldFlagged', async () => {
let rows = [conversation('c1', ['m2', 'm1'], { isFlagged: true, pendingSendIds: ['m2'] })]
const { store, requests } = await openThreads([], (request) => (request.url === '/conversations' ? threadsPage(rows) : undefined))
await store.openConversations('sent')
const syncs = threadRequests(requests).length
const result = await store.toggleConversationFlag('c1')
expect(patches(requests)).toEqual([{ ids: ['m1'], isFlagged: false }])
expect(threadRequests(requests).length).toBe(syncs + 1)
expect(result).toEqual({ heldFlagged: true })
expect(store.conversations[0]?.isFlagged).toBe(true)
// 붙잡힌 메일에는 표시가 없었다 — 다시 맞추면 풀린다
rows = [conversation('c1', ['m2', 'm1'], { isFlagged: false, pendingSendIds: ['m2'] })]
store.conversations.splice(0, 1, conversation('c1', ['m2', 'm1'], { isFlagged: true, pendingSendIds: ['m2'] }))
expect(await store.toggleConversationFlag('c1')).toEqual({ heldFlagged: false })
expect(store.conversations[0]?.isFlagged).toBe(false)
})
it('붙잡힌 메일이 없는 줄은 다시 맞추지 않는다', async () => {
const { store, requests } = await openThreads([conversation('c1', ['m2', 'm1'], { isFlagged: true })])
const syncs = threadRequests(requests).length
expect(await store.toggleConversationFlag('c1')).toEqual({ heldFlagged: false })
expect(store.conversations[0]?.isFlagged).toBe(false)
expect(threadRequests(requests).length).toBe(syncs)
})
it('읽기 화면의 별(한 통)이 실패하면 그 메일이 든 줄을 앞 값 그대로 되돌린다', async () => {
const { store, requests } = await openThreads(
[conversation('c1', ['m3', 'm2'], { isFlagged: false, latest: summary('m3', { isFlagged: false }) })],
(request) => (request.method === 'PATCH' && request.url === '/messages/m3' ? fail(503, 'MAIL_001') : undefined),
)
const toggling = store.toggleFlag('m3')
expect([store.conversations[0]?.isFlagged, store.conversations[0]?.latest.isFlagged]).toEqual([true, true])
await toggling
expect(requests.filter((request) => request.url === '/messages/m3')).toHaveLength(1)
expect([store.conversations[0]?.isFlagged, store.conversations[0]?.latest.isFlagged]).toEqual([false, false])
})
it('읽기 화면의 별(한 통)로 줄의 다른 메일이 중요인 줄을 풀어도 줄은 중요 그대로다 — 실패하면 앞 값', async () => {
const { store } = await openThreads(
[conversation('c1', ['m3', 'm2'], { isFlagged: true, latest: summary('m3', { isFlagged: true }) })],
(request) => (request.method === 'PATCH' && request.url === '/messages/m3' ? fail(503, 'MAIL_001') : undefined),
)
const toggling = store.toggleFlag('m3')
expect([store.conversations[0]?.isFlagged, store.conversations[0]?.latest.isFlagged]).toEqual([true, false])
await toggling
expect([store.conversations[0]?.isFlagged, store.conversations[0]?.latest.isFlagged]).toEqual([true, true])
})
})
describe('읽기 화면과 함께', () => {
it('연 메일을 읽음으로 — 한 통뿐인 줄은 줄도 읽음, 여러 통인 줄은 다른 메일을 몰라 그대로(다음 맞추기가 고친다)', async () => {
const { store } = await openThreads(
[
conversation('one', ['m1'], { isRead: false, latest: summary('m1', { isRead: false }) }),
conversation('two', ['m3', 'm2'], { isRead: false, latest: summary('m3', { isRead: false }) }),
],
(request) => {
if (request.method === 'PATCH' && request.url.startsWith('/messages/')) {
return ok(summary(request.url.slice('/messages/'.length), { isRead: true }))
}
return undefined
},
)
await store.markRead({ id: 'm1', isRead: false, mailboxIds: ['inbox'] })
await store.markRead({ id: 'm3', isRead: false, mailboxIds: ['inbox'] })
expect(store.conversations.map((row) => [row.id, row.isRead, row.latest.isRead])).toEqual([
['one', true, true],
['two', false, true],
])
})
it('줄 기준의 도우미 — 메일이 든 줄 · 그 줄을 누르면 열 메일 · 목록에 든 메일 · 뺀 뒤 이어서 열 메일', async () => {
const { store } = await openThreads([conversation('c1', ['m5']), conversation('c2', ['m4', 'm3']), conversation('c3', ['m2'])])
expect(store.rowIndexOfMessage('m3')).toBe(1)
expect(store.rowMessageIdAt(2)).toBe('m2')
expect(store.rowMessageIdAt(3)).toBeNull()
expect(store.isListedMessage('m3')).toBe(true)
expect(store.neighborAfterRemoval('m4')).toBe('m3')
expect(store.neighborAfterRemoval('m5')).toBe('m4')
})
it('읽기 화면의 삭제(한 통) — 줄에 메일이 남으면 줄은 남고 그 메일만 빠진다(목록에 든 메일이 아니다), 목록을 다시 맞춘다', async () => {
const sync = held()
let calls = 0
const { store, requests } = await openThreads([conversation('c1', ['m4', 'm3'])], (request) =>
request.url === '/conversations' && ++calls > 1 ? sync.promise : undefined,
)
await store.moveMessages([summary('m4')], 'trash')
await flushPromises()
expect(rowIds(store.conversations)).toEqual(['c1'])
expect(store.conversations[0]?.messageIds).toEqual(['m3'])
expect(store.isListedMessage('m4')).toBe(false)
expect(threadRequests(requests)).toHaveLength(2)
sync.resolve(threadsPage([conversation('c1', ['m3'])]))
await flushPromises()
expect(store.conversations[0]?.latest.id).toBe('m3')
})
})
})
+14 -2
View File
@@ -17,7 +17,7 @@ export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, MoveOptio
export type { MailboxOperationOutcome, MailboxOperationProgress } from './mail/mailbox-admin'
export type { ConversationDetailState } from './mail/conversation'
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 · 검색 결과 · 예약됨) · 고른 메일 · 열린 메일
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 · 대화로 묶은 메일함 · 검색 결과 · 예약됨) · 고른 메일 · 열린 메일
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
// mailboxes.ts 메일함 · 사용량 | list.ts 보이는 목록 · 이 탭이 바꾼 것 | selection.ts 고른 메일
// message.ts 열린 메일 · 읽음 · 중요 표시 · 외부 이미지 보기 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
@@ -42,7 +42,7 @@ export const useMailStore = defineStore('mail', () => {
const list = createListState({ mailApi, mailboxes: mailboxState, opened })
const selection = createSelection(list)
const message = createMessageActions({ mailApi, mailboxes: mailboxState, list, opened })
const bulk = createBulkActions({ mailApi, mailboxes: mailboxState, list, selection })
const bulk = createBulkActions({ mailApi, mailboxes: mailboxState, list, selection, opened })
const admin = createMailboxAdmin({ mailApi, mailboxes: mailboxState, list })
const scheduled = createScheduledState({
mailApi,
@@ -170,6 +170,14 @@ export const useMailStore = defineStore('mail', () => {
searchOrigin: list.searchOrigin,
searchIndexStatus: list.searchIndexStatus,
messages: list.messages,
// 대화로 묶은 메일함 (14단계) — 줄 기준의 도우미는 메일 목록에서도 그대로 쓴다
conversations: list.conversations,
isConversationList: list.isConversationList,
rowCount: list.rowCount,
listedSummaries: list.listedSummaries,
rowIndexOfMessage: list.rowIndexOfMessage,
rowMessageIdAt: list.rowMessageIdAt,
isListedMessage: list.isListedMessage,
pageInfo: list.pageInfo,
listStatus: list.listStatus,
listError: list.listError,
@@ -179,6 +187,7 @@ export const useMailStore = defineStore('mail', () => {
hasMore: list.hasMore,
neighborAfterRemoval: list.neighborAfterRemoval,
openMailbox: list.openMailbox,
openConversations: list.openConversations,
openScheduled: list.openScheduled,
openSearch: list.openSearch,
rememberSearchOrigin: list.rememberSearchOrigin,
@@ -189,6 +198,8 @@ export const useMailStore = defineStore('mail', () => {
checkedIds: selection.checkedIds,
checkedCount: selection.checkedCount,
checkedMessages: selection.checkedMessages,
checkedConversations: selection.checkedConversations,
checkedActionIds: selection.checkedActionIds,
checkState: selection.checkState,
toggleCheck: selection.toggleCheck,
checkRangeTo: selection.checkRangeTo,
@@ -240,6 +251,7 @@ export const useMailStore = defineStore('mail', () => {
retryConversationDetail: conversation.retryConversationDetail,
// 여러 통 처리 · 한 통 삭제
patchChecked: bulk.patchChecked,
toggleConversationFlag: bulk.toggleConversationFlag,
moveMessages: bulk.moveMessages,
moveChecked: bulk.moveChecked,
purgeMessages: bulk.purgeMessages,
+96 -10
View File
@@ -3,8 +3,11 @@
// (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다).
// 끝났을 때 보이는 목록이 다른 목록으로 바뀌어 있으면(sourceSeq) 그 목록 · 고른 것은 건드리지 않고, 개수와
// (메일함 목록이면) 그 목록을 서버 기준으로 다시 맞춘다 (settleStale).
// 대화로 묶은 메일함(14단계 — 계약 2절 "대화로 묶어 보기")은 고른 줄의 메일에서 붙잡힌 메일을 뺀 것(selection 의 checkedActionIds)을
// 같은 여러 통 API 로 100통씩 보낸다. 줄마다 어느 메일이 안 읽었는지 몰라 메일함 개수는 끝난 뒤 서버 기준으로 맞추고,
// 옮기거나 지운 뒤에는 목록을 다시 맞춘다 — 메일이 남은 줄의 마지막 메일 · 통 수를 서버 값으로 (list.syncVisibleList)
import { MAIL_BULK_MAX_IDS } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import type { ConversationSummary, MessageSummary } from '@/shared/types/mail'
import {
emptyBulkResult,
groupByOrigin,
@@ -13,9 +16,11 @@ import {
type BulkOrigin,
type BulkResult,
} from '@/shared/utils/mail-bulk'
import { conversationActionIds, conversationActionIdsOf, isConversationHeld } from '@/shared/utils/mail-conversation-list'
import { isAllMailboxSearch, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
import type { OpenedMessage } from './message'
import type { SelectionState } from './selection'
import type { BulkMoveResult, FlagChanges, MailApi, MailBulkResult, MoveOptions } from './types'
@@ -24,8 +29,12 @@ interface BulkDeps {
mailboxes: MailboxesState
list: ListState
selection: SelectionState
opened: OpenedMessage
}
/** 옮기기에 쓰는 메일 — id 와 되돌릴 곳을 정할 메일함만 본다 (대화 목록은 줄의 메일 id 만 안다) */
type MoveTarget = Pick<MessageSummary, 'id' | 'mailboxIds'>
/** 요청 하나의 ids 한도(100)로 나눠 차례로 보낸다 */
function sendBulk(ids: readonly string[], send: BulkChunkSender): Promise<BulkResult> {
return sendInChunks(ids, MAIL_BULK_MAX_IDS, send)
@@ -35,7 +44,7 @@ function hasLeftovers(result: BulkResult): boolean {
return result.skippedIds.length > 0 || result.unsentIds.length > 0
}
export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkDeps) {
export function createBulkActions({ mailApi, mailboxes, list, selection, opened }: BulkDeps) {
/** 지금 목록이 시작할 때의 목록인지 — 아니면 끝난 일을 그 목록에 적용하지 않는다 */
function captureList(): () => boolean {
const identity = list.sourceSeq.value
@@ -57,6 +66,7 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
* 고른 것은 그대로 둔다. 알림은 띄우지 않는다 (목록이 바로 바뀐다 — 요청 실패는 인터셉터가 알린다)
*/
async function patchChecked(changes: FlagChanges): Promise<MailBulkResult> {
if (list.isConversationList.value) return patchConversations(selection.checkedConversations.value, changes)
const targets = selection.checkedMessages.value
if (targets.length === 0) return { ...emptyBulkResult(), stale: false }
const isStale = captureList()
@@ -93,8 +103,68 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
return { ...result, stale: false }
}
/**
* 대화 줄의 읽음 · 중요 표시 (14단계) — 줄의 메일(붙잡힌 메일은 빼고)에 보낸다. 화면을 먼저 바꾸고, 서버가 바꾸지 못했거나 보내지 못한
* 메일이 든 줄은 앞 값으로 되돌린 뒤 목록을 서버 기준으로 다시 맞춘다(줄의 다른 메일은 바뀌었을 수 있다). 고른 것은 그대로 둔다.
* 안 읽은 수는 줄마다 몰라 미리 고치지 않고 끝난 뒤 메일함 목록을 다시 받는다. 줄의 별(중요 표시)도 이 길이다 (toggleConversationFlag)
*/
async function patchConversations(
rows: readonly ConversationSummary[],
changes: FlagChanges,
): Promise<MailBulkResult> {
const ids = conversationActionIdsOf(rows)
if (ids.length === 0) return { ...emptyBulkResult(), stale: false }
const isStale = captureList()
const targets = new Set(ids)
// 줄은 바꿀 때마다 새 객체로 바꿔 끼우므로(withConversationFlags) 받은 그대로가 앞 값이다
const originals = [...rows]
const current = opened.selected.value
const openedBefore =
current && targets.has(current.id) ? { id: current.id, isRead: current.isRead, isFlagged: current.isFlagged } : null
list.setFlagsOf(targets, changes)
list.beginPending(ids, changes)
const result = await sendBulk(ids, async (chunkIds) => {
const patched = await mailApi.updateMessages({ ids: chunkIds, ...changes })
return { done: patched.updatedIds, skipped: patched.failedIds }
})
list.endPending(ids, changes)
if (isStale()) {
settleStale()
return { ...result, stale: true }
}
const failed = new Set([...result.skippedIds, ...result.unsentIds])
if (failed.size > 0) {
list.restoreConversationFlags(originals.filter((row) => conversationActionIds(row).some((id) => failed.has(id))))
const shown = opened.selected.value
if (openedBefore && shown?.id === openedBefore.id && failed.has(openedBefore.id)) {
opened.selected.value = { ...shown, isRead: openedBefore.isRead, isFlagged: openedBefore.isFlagged }
}
void list.syncVisibleList()
}
if (changes.isRead !== undefined) void mailboxes.loadMailboxes({ background: true })
return { ...result, stale: false }
}
/**
* 대화 줄의 별 — 줄에 중요 표시한 메일이 있으면 모두 풀고, 없으면 모두 표시한다 (메일이 모두 붙잡힌 줄은 바꾸지 않는다).
* 붙잡힌 메일은 빼고 보내므로(계약) 그 메일이 든 줄을 풀었으면 서버 기준으로 한 번 맞춰 본다 — 줄의 중요 표시가 붙잡힌 메일에만 있었으면
* 줄은 그대로 중요 표시이고, heldFlagged 로 알린다(화면이 그 까닭을 알린다 — 메일을 열어 바꾼다)
*/
async function toggleConversationFlag(rowId: string): Promise<{ heldFlagged: boolean }> {
const row = list.conversations.value.find((entry) => entry.id === rowId)
if (!row || isConversationHeld(row)) return { heldFlagged: false }
const flag = !row.isFlagged
const result = await patchConversations([row], { isFlagged: flag })
const hasHeld = conversationActionIds(row).length < row.messageIds.length
if (flag || !hasHeld || result.stale || result.unsentIds.length > 0) return { heldFlagged: false }
await list.syncVisibleList()
return { heldFlagged: list.conversations.value.find((entry) => entry.id === rowId)?.isFlagged === true }
}
/** 되돌리기에 쓸 원래 메일함 — 목록이 가리키는 메일함, 없으면(전체 검색) 옮길 곳이 아닌 첫 메일함 */
function originMailboxOf(message: MessageSummary, targetId: string): string | null {
function originMailboxOf(message: MoveTarget, targetId: string): string | null {
const scopeId = scopeMailboxIdOf(list.listSource.value)
if (scopeId && message.mailboxIds.includes(scopeId)) return scopeId
return message.mailboxIds.find((id) => id !== targetId) ?? message.mailboxIds[0] ?? null
@@ -125,9 +195,10 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
* 스팸 신고 · 스팸 아님은 스팸 표시(isJunk)를 같은 요청에 함께 보낸다 (계약 2절 "스팸 · 원문").
* 응답을 받은 뒤 옮긴 메일(updatedIds)만 목록에서 빼고 개수를 다시 맞춘다. 그대로 둔 메일 · 보내지 못한 메일이 있으면
* (503 은 일부만 바뀌었을 수 있다) 보이는 목록을 서버 기준으로 다시 맞춘다. targets 는 고른 메일이거나 열린 메일 한 통이다
* (대화 목록은 고른 줄의 메일 — moveChecked). 대화 목록은 늘 다시 맞춘다 — 메일이 남은 줄의 마지막 메일 · 통 수가 바뀐다
*/
async function moveMessages(
targets: readonly MessageSummary[],
targets: readonly MoveTarget[],
targetId: string,
options: MoveOptions = {},
): Promise<BulkMoveResult> {
@@ -152,15 +223,22 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
settleStale()
} else {
applyMoved(result.doneIds, targetId)
if (hasLeftovers(result)) void list.syncVisibleList({ includeSearch: true })
if (hasLeftovers(result) || list.isConversationList.value) void list.syncVisibleList({ includeSearch: true })
mailboxes.syncCounts()
}
return { ...result, origins: groupByOrigin(result.doneIds, origins), stale, ...junk }
}
/** 고른 줄의 옮길 메일 — 메일 목록은 고른 메일, 대화 목록은 고른 줄의 메일(그 메일함에 든 것 — 되돌리기는 그 메일함으로) */
function checkedMoveTargets(): MoveTarget[] {
if (!list.isConversationList.value) return selection.checkedMessages.value
const mailboxId = scopeMailboxIdOf(list.listSource.value)
return selection.checkedActionIds.value.map((id) => ({ id, mailboxIds: mailboxId ? [mailboxId] : [] }))
}
/** 고른 메일을 옮긴다 — 하나라도 옮겼으면 고른 것을 푼다 (요청이 통째로 실패했으면 다시 해 볼 수 있게 둔다) */
async function moveChecked(targetId: string, options: MoveOptions = {}): Promise<BulkMoveResult> {
const result = await moveMessages(selection.checkedMessages.value, targetId, options)
const result = await moveMessages(checkedMoveTargets(), targetId, options)
if (!result.stale && result.doneIds.length > 0) selection.clearChecked()
return result
}
@@ -182,15 +260,15 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
settleStale()
} else {
list.removeFromList(new Set(result.doneIds))
if (hasLeftovers(result)) void list.syncVisibleList({ includeSearch: true })
if (hasLeftovers(result) || list.isConversationList.value) void list.syncVisibleList({ includeSearch: true })
mailboxes.syncCounts()
}
return { ...result, stale }
}
/** 고른 메일을 영구 삭제한다 — 하나라도 지웠으면 고른 것을 푼다 */
/** 고른 메일을 영구 삭제한다 — 하나라도 지웠으면 고른 것을 푼다 (대화 목록은 고른 줄의 메일) */
async function purgeChecked(): Promise<MailBulkResult> {
const result = await purgeMessages(selection.checkedMessages.value.map((message) => message.id))
const result = await purgeMessages(selection.checkedActionIds.value)
if (!result.stale && result.doneIds.length > 0) selection.clearChecked()
return result
}
@@ -220,5 +298,13 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
return total
}
return { patchChecked, moveMessages, moveChecked, purgeMessages, purgeChecked, restoreMoved }
return {
patchChecked,
toggleConversationFlag,
moveMessages,
moveChecked,
purgeMessages,
purgeChecked,
restoreMoved,
}
}
+1 -1
View File
@@ -403,7 +403,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
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)
const listed = list.listedSummary(id)
if (listed) return listed.isRead
return markedRead.has(id) || fallback
}
+273 -30
View File
@@ -1,7 +1,14 @@
// 메일 스토어 모듈 — 보이는 목록(메일함 또는 검색 결과): 열기 · 더 보기 · 새 메일 확인으로 맞추기 · 이 탭이 바꾼 것 (mail.store.ts 가 조립한다)
// 메일 스토어 모듈 — 보이는 목록(메일함 · 대화로 묶은 메일함 · 검색 결과 · 예약됨): 열기 · 더 보기 · 새 메일 확인으로 맞추기 · 이 탭이 바꾼 것
// (mail.store.ts 가 조립한다)
//
// 대화로 묶은 메일함 (14단계 — 계약 2절 "대화로 묶어 보기"): 출처가 conversations 면 줄은 conversations(대화 하나가 한 줄)이고 messages 는 비어 있다.
// 열기 · 더 보기 · 맞추기는 GET /conversations 로 같은 순번 규칙을 따른다. 이 탭이 바꾼 표시 · 뺀 메일은 메일 id 로 받아 그 메일이 든 줄에 적는다
// (mail-conversation-list.ts — 줄의 메일을 모두 바꿨을 때만 줄 전체가 읽음 · 중요 해제). 출처를 몰라도 되게 줄 기준의 도우미를 둔다:
// rowCount · listedSummaries(줄마다 마지막 메일) · rowIndexOfMessage · rowMessageIdAt · isListedMessage · listedSummary
//
// 늦게 온 응답 막기:
// - listSeq: 목록을 새로 열 때마다(같은 목록을 보이는 채로 다시 불러올 때 포함) — 그 전에 보낸 목록 요청의 응답은 버린다
// - listSeq: 목록을 새로 열 때마다(같은 목록을 보이는 채로 다시 불러올 때 포함) — 그 전에 보낸 목록 요청의 응답은 버린다.
// 그 요청은 끊기도 한다(listAbort — 짐작이 틀림 · 빠르게 켜고 끔 · 메일함 바꿈, 엔진 방식의 묶은 목록은 한 요청이 15초까지 — 14단계 리뷰 L7)
// - sourceSeq: 목록의 출처(메일함 · 검색)가 바뀔 때마다 — 고른 것을 풀고(selection.ts), 끝난 여러 통 처리가
// 아직 같은 목록인지 본다(bulk.ts — 다른 목록이면 그 목록을 건드리지 않는다)
// - localGeneration: 이 탭이 목록 · 열린 메일을 바꿀 때마다(읽음 · 중요 표시 · 빼기 · 옮기기 · 보내던 표시가 끝남) —
@@ -19,16 +26,35 @@ import { toApiError, type ApiError } from '@/composables/useApi'
import type { MailRequestOptions } from '@/composables/useMail'
import { MAIL_LIST_SYNC_MAX } from '@/shared/constants/mail'
import { MAIL_SEARCH_FILL_MAX_REQUESTS } from '@/shared/constants/mail-search'
import type { Mailbox, MessageListItem, MessageSummary, SearchIndexStatus } from '@/shared/types/mail'
import type {
ConversationListPage,
ConversationSummary,
Mailbox,
MessageListItem,
MessageSummary,
SearchIndexStatus,
} from '@/shared/types/mail'
import { DEFAULT_PAGE_SIZE, type PageInfo, type PaginatedResult } from '@/shared/types/pagination'
import {
appendConversationPage,
conversationHasMessage,
conversationNeighborAfterRemoval,
conversationRowIndexOf,
mergeRefreshedConversations,
withConversationFlags,
withConversationFlagsOf,
withoutConversationMessages,
} from '@/shared/utils/mail-conversation-list'
import { mergeRefreshedList } from '@/shared/utils/mail-list-merge'
import {
isConversationSource,
isSearchTooLong,
listQueryOf,
sameListSource,
scopeMailboxIdOf,
SCHEDULED_LIST_SOURCE,
searchSourceOf,
type ConversationListSource,
type MailListSource,
type SearchListSource,
} from '@/shared/utils/mail-list-source'
@@ -48,7 +74,7 @@ interface ListDeps {
}
export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
/** 목록의 출처 — 메일함 하나 또는 검색 결과 (mail-list-source.ts) */
/** 목록의 출처 — 메일함 하나 · 대화로 묶은 메일함 · 검색 결과 · 예약됨 (mail-list-source.ts) */
const listSource = ref<MailListSource | null>(null)
/** 목록의 출처가 바뀐 횟수 (위 설명) */
const sourceSeq = ref<number>(0)
@@ -75,6 +101,21 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
*/
const searchIndexStatus = ref<SearchIndexStatus | null>(null)
const messages = ref<MessageListItem[]>([])
/** 대화로 묶은 메일함의 줄 (14단계) — 그 출처일 때만 차고, 그때 messages 는 비어 있다 */
const conversations = ref<ConversationSummary[]>([])
/** 대화로 묶은 메일함을 보이는 중 */
const isConversationList = computed<boolean>(() => isConversationSource(listSource.value))
/** 보이는 줄 수 — 메일 목록은 메일, 대화 목록은 대화 */
const rowCount = computed<number>(() =>
isConversationList.value ? conversations.value.length : messages.value.length,
)
/**
* 보이는 목록의 메일 요약 — 메일 목록은 그 메일들, 대화 목록은 줄마다 마지막 메일 (붙잡은 발송 지켜보기 · 같은 대화 칸의 링크 등
* 줄 모양을 몰라도 되는 곳이 쓴다)
*/
const listedSummaries = computed<MessageSummary[]>(() =>
isConversationList.value ? conversations.value.map((row) => row.latest) : messages.value,
)
const pageInfo = ref<PageInfo | null>(null)
const listStatus = ref<LoadStatus>('idle')
const listError = ref<ApiError | null>(null)
@@ -94,6 +135,11 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
let searchIndexSeq = 0
/** 지금 이어 받는 일 (runLoading) — 그사이 누른 [다음] · [더 찾기]가 끝나기를 기다린다 */
let loading: Promise<void> | null = null
/**
* 지금 목록의 요청을 끊는 손잡이 — 목록을 새로 열거나 비우면 앞 목록의 요청(첫 쪽 · 더 보기 · 이어 찾기 · 맞추기)을 끊는다.
* 그 응답은 순번(listSeq)으로도 버리지만 서버 · 연결을 붙잡지 않게 (끊은 요청은 알리지 않는다 — useApi)
*/
let listAbort = new AbortController()
/**
* 보내는 중인 읽음 · 중요 표시 — 그사이 도착한 목록(새 메일 확인)이 옛 값으로 되돌려 보이지 않게 덮어쓴다.
* 화면에 그리지 않으므로 반응형이 아니다
@@ -111,6 +157,13 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
{ flush: 'sync' },
)
/** 앞 목록의 요청을 끊고 새 손잡이를 준다 — 목록을 새로 열 때 · 비울 때 */
function renewListAbort(): AbortSignal {
listAbort.abort()
listAbort = new AbortController()
return listAbort.signal
}
/** 같은 출처를 새 목록으로 다시 연다 (같은 검색어로 다시 찾기) — 출처가 바뀐 것과 같게 다룬다 */
function renewSource() {
sourceSeq.value += 1
@@ -124,39 +177,82 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
}
function setLocalFlags(id: string, changes: LocalChanges) {
messages.value = messages.value.map((message) => (message.id === id ? { ...message, ...changes } : message))
if (opened.selected.value?.id === id) opened.selected.value = { ...opened.selected.value, ...changes }
setFlagsOf(new Set([id]), changes)
}
/**
* 여러 메일의 표시를 한 번에 — 목록 · 열린 메일 · 대화 줄(그 메일이 든 줄 — 줄의 메일을 모두 바꿨을 때만 줄 전체가 읽음 · 중요 해제다,
* mail-conversation-list.ts 의 withConversationFlags)
*/
function setFlagsOf(ids: ReadonlySet<string>, changes: LocalChanges) {
if (ids.size === 0) return
messages.value = messages.value.map((message) => (ids.has(message.id) ? { ...message, ...changes } : message))
if (conversations.value.length > 0) {
conversations.value = conversations.value.map((row) => withConversationFlags(row, ids, changes))
}
const current = opened.selected.value
if (current && ids.has(current.id)) opened.selected.value = { ...current, ...changes }
bumpLocalChange()
}
/** 대화 줄의 읽음 · 중요 표시를 앞 값으로 되돌린다 — 보내지 못한 메일이 든 줄 (여러 통 처리 — bulk.ts) */
function restoreConversationFlags(originals: readonly ConversationSummary[]) {
if (originals.length === 0) return
const byId = new Map(originals.map((row) => [row.id, row]))
conversations.value = conversations.value.map((row) => {
const original = byId.get(row.id)
return original ? withConversationFlagsOf(row, original) : row
})
bumpLocalChange()
}
/**
* 서버가 돌려준 요약으로 목록 · 열린 메일을 맞춘다 — 요약(PATCH 응답)에는 검색 강조가 없으므로 목록 줄의 강조는 그대로 둔다 (14단계)
* 서버가 돌려준 요약으로 목록 · 열린 메일을 맞춘다 — 요약(PATCH 응답)에는 검색 강조가 없으므로 목록 줄의 강조는 그대로 둔다 (14단계).
* 대화 줄은 그 메일이 마지막 메일이면 바꿔 끼우고, 표시는 setFlagsOf 와 같은 규칙으로 적는다
*/
function applySummary(summary: MessageSummary) {
messages.value = messages.value.map((message) => {
if (message.id !== summary.id) return message
return message.searchHighlight === undefined ? summary : { ...summary, searchHighlight: message.searchHighlight }
})
if (conversations.value.length > 0) {
const ids = new Set([summary.id])
const flags = { isRead: summary.isRead, isFlagged: summary.isFlagged }
conversations.value = conversations.value.map((row) => {
if (row.latest.id !== summary.id && !conversationHasMessage(row, summary.id)) return row
return withConversationFlags(row.latest.id === summary.id ? { ...row, latest: summary } : row, ids, flags)
})
}
if (opened.selected.value?.id === summary.id) opened.selected.value = { ...opened.selected.value, ...summary }
bumpLocalChange()
}
/** 목록에서 뺀다 — 전체 개수도 그만큼 줄인다 */
/** 목록에서 뺀다 — 전체 개수도 그만큼 줄인다 (대화 줄은 메일이 모두 빠진 줄만 빠지고 그 수를 센다) */
function removeFromList(ids: ReadonlySet<string>) {
if (ids.size === 0) return
const before = messages.value.length
messages.value = messages.value.filter((message) => !ids.has(message.id))
const removed = before - messages.value.length
let removed = before - messages.value.length
if (conversations.value.length > 0) {
const result = withoutConversationMessages(conversations.value, ids)
conversations.value = result.rows
removed += result.removedRows
}
if (removed > 0 && pageInfo.value && pageInfo.value.total !== null) {
pageInfo.value = { ...pageInfo.value, total: Math.max(0, pageInfo.value.total - removed) }
}
bumpLocalChange()
}
/** 목록에 남긴 채 메일함만 바꾼다 (전체 메일함 검색 결과에서 옮긴 메일) */
/** 목록에 남긴 채 메일함만 바꾼다 (전체 메일함 검색 결과에서 옮긴 메일 · 이 목록의 메일함으로 옮긴 메일) */
function relocate(ids: ReadonlySet<string>, mailboxId: string) {
if (ids.size === 0) return
messages.value = messages.value.map((message) => (ids.has(message.id) ? { ...message, mailboxIds: [mailboxId] } : message))
if (conversations.value.length > 0) {
conversations.value = conversations.value.map((row) =>
ids.has(row.latest.id) ? { ...row, latest: { ...row.latest, mailboxIds: [mailboxId] } } : row,
)
}
const current = opened.selected.value
if (current && ids.has(current.id)) opened.selected.value = { ...current, mailboxIds: [mailboxId] }
bumpLocalChange()
@@ -194,6 +290,29 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
})
}
/**
* 대화 줄에 보내는 중인 표시를 덮어쓴다 — 같은 값으로 보내는 메일끼리 모아 적는다(줄의 메일을 모두 보내는 중이면 줄 전체가 그 값 —
* 여러 통 처리가 도는 사이 새 메일 확인이 옛 값으로 줄을 되돌리지 않게)
*/
function withPendingConversationFlags(rows: readonly ConversationSummary[]): ConversationSummary[] {
if (pendingFlags.size === 0) return [...rows]
const groups = new Map<string, { ids: Set<string>; changes: FlagChanges }>()
for (const [id, pending] of pendingFlags) {
for (const [key, value] of Object.entries(pending) as [keyof FlagChanges, boolean | undefined][]) {
if (value === undefined) continue
const name = `${key}:${String(value)}`
const group = groups.get(name) ?? { ids: new Set<string>(), changes: { [key]: value } }
group.ids.add(id)
groups.set(name, group)
}
}
return rows.map((row) => {
let next = row
for (const group of groups.values()) next = withConversationFlags(next, group.ids, group.changes)
return next
})
}
/**
* 다시 받은 목록의 값으로 열린 메일의 표시(읽음 · 중요 · 메일함 · 붙잡은 발송)를 맞춘다 — 엔진이 붙잡은 발송을 풀었으면(9단계)
* 읽기 화면의 예약 안내 줄 · 보내지 않은 메일의 삭제 확인도 함께 내려간다
@@ -220,13 +339,49 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
}
}
/** 지운 뒤 이어서 보여 줄 메일 — 다음 메일, 없으면 이전 메일 (목록 밖의 메일이면 null) */
/**
* 지운 뒤 이어서 보여 줄 메일 — 다음 메일, 없으면 이전 메일 (목록 밖의 메일이면 null).
* 대화 목록은 같은 결로 그 줄의 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄 · 앞 줄의 마지막 메일 (mail-conversation-list.ts)
*/
function neighborAfterRemoval(id: string): string | null {
if (isConversationList.value) return conversationNeighborAfterRemoval(conversations.value, id)
const index = messages.value.findIndex((message) => message.id === id)
if (index === -1) return null
return messages.value[index + 1]?.id ?? messages.value[index - 1]?.id ?? null
}
// ── 줄 기준의 도우미 (출처를 몰라도 되게 — 머리말) ───────────────────
/** 그 메일이 든 줄의 차례 — 대화 목록은 그 줄의 마지막 메일이거나 줄에 든 메일. 목록 밖이면 -1 */
function rowIndexOfMessage(id: string): number {
if (isConversationList.value) return conversationRowIndexOf(conversations.value, id)
return messages.value.findIndex((message) => message.id === id)
}
/** 그 줄을 누르면 열 메일 — 대화 목록은 그 줄의 마지막 메일. 없는 줄이면 null */
function rowMessageIdAt(index: number): string | null {
if (index < 0) return null
if (isConversationList.value) return conversations.value[index]?.latest.id ?? null
return messages.value[index]?.id ?? null
}
/** 보이는 목록에 든 메일인가 — 뺀 뒤 다음 메일로 넘어갈지 가른다 (대화 목록은 줄에 든 메일 — 뺀 옛 마지막 메일은 아니다) */
function isListedMessage(id: string): boolean {
if (isConversationList.value) return conversations.value.some((row) => conversationHasMessage(row, id))
return messages.value.some((message) => message.id === id)
}
/** 그 메일이 든 대화 줄 — 지금 값 그대로 (한 통의 표시가 실패하면 이 값으로 되돌린다 — message.ts). 메일 목록이면 빈 배열 */
function conversationsWith(id: string): ConversationSummary[] {
return conversations.value.filter((row) => row.latest.id === id || conversationHasMessage(row, id))
}
/** 보이는 목록이 아는 그 메일의 요약 — 대화 목록은 줄의 마지막 메일만 안다. 모르면 null */
function listedSummary(id: string): MessageSummary | null {
if (isConversationList.value) return conversations.value.find((row) => row.latest.id === id)?.latest ?? null
return messages.value.find((message) => message.id === id) ?? null
}
// ── 목록 열기 · 맞추기 ───────────────────────────────
/**
@@ -234,7 +389,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
* cursor 는 해석하지 않고 그대로 넘긴다
*/
function fetchPage(
source: MailListSource,
source: Exclude<MailListSource, ConversationListSource>,
page: { cursor?: string; limit: number },
options?: MailRequestOptions,
): Promise<PaginatedResult<MessageListItem>> {
@@ -242,6 +397,15 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
return mailApi.listMessages({ ...listQueryOf(source), ...page }, options)
}
/** 대화로 묶은 메일함의 한 쪽 — GET /conversations (14단계). cursor 는 해석하지 않고 그대로 넘긴다 */
function fetchConversations(
source: ConversationListSource,
page: { cursor?: string; limit: number },
options?: MailRequestOptions,
): Promise<ConversationListPage> {
return mailApi.listConversations({ mailboxId: source.mailboxId, ...page }, options)
}
/** 예약됨 목록의 전체 수를 받았다 — 메일함 패널의 「예약됨」 개수가 이 값을 쓴다 (scheduled.ts) */
let onScheduledTotal: ((total: number | null) => void) | null = null
@@ -286,7 +450,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
while (seq === listSeq && sent < MAIL_SEARCH_FILL_MAX_REQUESTS && wantsMoreResults(source, total)) {
const cursor = pageInfo.value?.nextCursor
if (!cursor) return
const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE })
const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE }, { signal: listAbort.signal })
sent += 1
if (seq !== listSeq) return
total += appendPage(page)
@@ -312,17 +476,20 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
* 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 보이던 목록을 둔 채 새로 고친다.
* 검색 결과가 한 쪽에 못 미치고 서버에 더 있으면 이어 받는다 (머리말 "검색 결과 이어 받기") — 첫 쪽이 비었으면 보이던 채로 다시 찾을 때도 "찾는 중"(뼈대).
* 검색어와 조건을 합친 낱말이 서버 한도를 넘는 검색(손으로 고친 주소)은 묻지 않는다 — 목록 틀이 그 까닭을 보인다 (isSearchTooLong)
* 대화로 묶은 메일함(14단계)은 GET /conversations 의 첫 쪽이다 — 줄 모양만 다르고 같은 순번 · 상태를 쓴다
*/
async function openList(next: MailListSource): Promise<void> {
const keepVisible = sameListSource(listSource.value, next) && listStatus.value === 'ready'
listSource.value = next
listSeq += 1
const seq = listSeq
// 앞 목록을 이어 받던 일은 이제 쓰지 않는다 (그 응답은 순번으로 버린다)
// 앞 목록의 요청은 끊는다 — 이어 받던 일도 이제 쓰지 않는다 (그 응답은 순번으로도 버린다)
const signal = renewListAbort()
isLoadingMore.value = false
if (isSearchTooLong(next)) {
messages.value = []
conversations.value = []
pageInfo.value = null
listError.value = null
isRefreshing.value = false
@@ -334,25 +501,46 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
isRefreshing.value = true
} else {
messages.value = []
conversations.value = []
pageInfo.value = null
listStatus.value = 'loading'
listError.value = null
}
let page: PaginatedResult<MessageListItem>
try {
page = await fetchPage(
next,
{ limit: DEFAULT_PAGE_SIZE },
// 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
{ silent: keepVisible ? undefined : true },
)
} catch (error) {
// 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
const firstOptions: MailRequestOptions = { silent: keepVisible ? undefined : true, signal }
/** 첫 쪽을 받지 못했다 — 보이는 채로 새로 고치던 것이면 그대로 두고, 아니면 오류 상태 */
const failFirst = (error: unknown) => {
if (seq !== listSeq) return
isRefreshing.value = false
if (keepVisible) return
listStatus.value = 'error'
listError.value = toApiError(error)
}
if (next.kind === 'conversations') {
let rows: ConversationListPage
try {
rows = await fetchConversations(next, { limit: DEFAULT_PAGE_SIZE }, firstOptions)
} catch (error) {
failFirst(error)
return
}
if (seq !== listSeq) return
conversations.value = rows.items
pageInfo.value = rows.pageInfo
listError.value = null
isRefreshing.value = false
listStatus.value = 'ready'
return
}
let page: PaginatedResult<MessageListItem>
try {
page = await fetchPage(next, { limit: DEFAULT_PAGE_SIZE }, firstOptions)
} catch (error) {
failFirst(error)
return
}
if (seq !== listSeq) return
@@ -376,6 +564,14 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
await openList({ kind: 'mailbox', mailboxId })
}
/**
* 대화로 묶은 메일함 열기 (14단계) — 같은 목록으로 돌아오면 보이던 줄을 둔 채 새로 고친다.
* 묶기를 켜고 끄면 출처가 바뀌어 앞 목록의 응답을 버리고 처음 쪽부터 받는다 (메일함 화면이 설정에 따라 부른다)
*/
async function openConversations(mailboxId: string): Promise<void> {
await openList({ kind: 'conversations', mailboxId })
}
/** 예약됨 열기 (9단계) — 같은 목록으로 돌아오면 보이던 목록을 둔 채 새로 고친다 (보낸 메일은 저절로 빠진다) */
async function openScheduled(): Promise<void> {
await openList(SCHEDULED_LIST_SOURCE)
@@ -444,8 +640,19 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
if (!cursor || !source || isRefreshing.value) return
const seq = listSeq
const signal = listAbort.signal
if (source.kind === 'conversations') {
await runLoading(seq, async () => {
const page = await fetchConversations(source, { cursor, limit: DEFAULT_PAGE_SIZE }, { signal })
if (seq !== listSeq) return
const merged = appendConversationPage(conversations.value, pageInfo.value, page)
conversations.value = merged.rows
pageInfo.value = merged.pageInfo
})
return
}
await runLoading(seq, async () => {
const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE })
const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE }, { signal })
if (seq !== listSeq) return
const added = appendPage(page)
reportTotal(source, page.pageInfo)
@@ -458,7 +665,8 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
* 고른 메일 · 열린 메일은 그대로, 다시 받은 범위 밖의 메일은 그대로 둔다(mail-list-merge.ts). 실패는 알리지 않는다.
* 검색 결과는 새 메일 확인으로 저절로 바꾸지 않는다 — includeSearch 는 사용자가 한 일(되돌리기 · 여러 통 처리) 뒤에만 준다.
* 묻는 사이 이 탭이 목록을 바꿨으면(localGeneration) 그 응답은 버리고 한 번 더 맞춘다.
* 예약됨(9단계)은 늘 맞춘다 — 보낸 메일이 저절로 빠진다. 새로 생긴 줄은 새 메일이 아니라 "새 메일 N통" 줄을 띄우지 않는다
* 예약됨(9단계)은 늘 맞춘다 — 보낸 메일이 저절로 빠진다. 새로 생긴 줄은 새 메일이 아니라 "새 메일 N통" 줄을 띄우지 않는다.
* 대화로 묶은 메일함(14단계)은 불러온 줄 수만큼(최대 100줄) GET /conversations 로 같은 규칙으로 맞춘다 (mail-conversation-list.ts)
*/
async function syncVisibleList(options: { includeSearch?: boolean } = {}, attempt = 0): Promise<void> {
const source = listSource.value
@@ -467,14 +675,35 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
const seq = listSeq
const generation = localGeneration
const limit = Math.min(MAIL_LIST_SYNC_MAX, Math.max(DEFAULT_PAGE_SIZE, messages.value.length))
const limit = Math.min(MAIL_LIST_SYNC_MAX, Math.max(DEFAULT_PAGE_SIZE, rowCount.value))
/** 받은 응답을 쓸까 — 그사이 목록을 새로 열었으면 버리고, 이 탭이 목록을 바꿨으면 버리고 한 번 더 맞춘다 */
async function accepts(): Promise<boolean> {
if (seq !== listSeq || listStatus.value !== 'ready') return false
if (generation === localGeneration) return true
if (attempt === 0) await syncVisibleList(options, attempt + 1)
return false
}
try {
const page = await fetchPage(source, { limit }, { silent: true })
if (seq !== listSeq || listStatus.value !== 'ready') return
if (generation !== localGeneration) {
if (attempt === 0) await syncVisibleList(options, attempt + 1)
if (source.kind === 'conversations') {
const rows = await fetchConversations(source, { limit }, { silent: true, signal: listAbort.signal })
if (!(await accepts())) return
const mergedRows = mergeRefreshedConversations({
current: conversations.value,
currentPageInfo: pageInfo.value,
fetched: withPendingConversationFlags(rows.items),
fetchedPageInfo: rows.pageInfo,
})
conversations.value = mergedRows.rows
pageInfo.value = mergedRows.pageInfo
refreshSelectedFrom(mergedRows.rows.map((row) => row.latest))
listSyncSeq += 1
listSync.value = { seq: listSyncSeq, addedAbove: mergedRows.addedAbove }
return
}
const page = await fetchPage(source, { limit }, { silent: true, signal: listAbort.signal })
if (!(await accepts())) return
const merged = mergeRefreshedList({
current: messages.value,
currentPageInfo: pageInfo.value,
@@ -494,11 +723,13 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
function reset() {
listSeq += 1
renewListAbort()
searchIndexSeq += 1
listSource.value = null
searchOrigin.value = null
searchIndexStatus.value = null
messages.value = []
conversations.value = []
pageInfo.value = null
listStatus.value = 'idle'
listError.value = null
@@ -517,6 +748,10 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
searchOrigin,
searchIndexStatus,
messages,
conversations,
isConversationList,
rowCount,
listedSummaries,
pageInfo,
listStatus,
listError,
@@ -525,6 +760,8 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
listSync,
hasMore,
setLocalFlags,
setFlagsOf,
restoreConversationFlags,
applySummary,
removeFromList,
relocate,
@@ -532,8 +769,14 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
endPending,
pendingFlagsOf,
neighborAfterRemoval,
rowIndexOfMessage,
rowMessageIdAt,
isListedMessage,
listedSummary,
conversationsWith,
openList,
openMailbox,
openConversations,
openScheduled,
openSearch,
rememberSearchOrigin,
+12 -5
View File
@@ -8,6 +8,7 @@ import { ref } from 'vue'
import { toApiError, type ApiError } from '@/composables/useApi'
import type { MessageLargeFile, ShareLink } from '@/shared/types/large-file'
import type { MessageDetail, MessageHighlightQuery } from '@/shared/types/mail'
import { mailboxListIdOf } from '@/shared/utils/mail-list-source'
import { messageHighlightKey } from '@/shared/utils/mail-search'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
@@ -169,14 +170,17 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
}
}
/** 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
/**
* 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다. 대화로 묶은 목록(14단계)은 한 통만 바꾸면 줄의 표시를 다 알 수 없어
* (다른 메일 · 붙잡힌 메일에도 표시가 있을 수 있다) 실패하면 그 메일이 든 줄을 앞 값 그대로 되돌린다 — 여러 통 처리와 같은 결 (리뷰 L1)
*/
async function toggleFlag(id: string): Promise<void> {
const current =
list.messages.value.find((message) => message.id === id) ??
(opened.selected.value?.id === id ? opened.selected.value : null)
// 대화로 묶은 목록은 줄의 마지막 메일만 안다 — 모르면 열린 메일의 값으로 (14단계)
const current = list.listedSummary(id) ?? (opened.selected.value?.id === id ? opened.selected.value : null)
if (!current) return
const changes: FlagChanges = { isFlagged: !current.isFlagged }
const rowsBefore = list.conversationsWith(id)
list.setLocalFlags(id, changes)
list.beginPending([id], changes)
try {
@@ -186,6 +190,7 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
} catch {
list.endPending([id], changes)
list.setLocalFlags(id, { isFlagged: current.isFlagged })
list.restoreConversationFlags(rowsBefore)
}
}
@@ -203,7 +208,9 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
mailboxes.syncCounts()
const source = list.listSource.value
const trashId = mailboxes.mailboxByRole('trash')?.id ?? null
if (source?.kind === 'mailbox' && (source.mailboxId === mailboxId || source.mailboxId === trashId)) {
// 메일함 목록(대화로 묶은 목록 포함 — 14단계)이 옮긴 곳이나 휴지통이면 다시 받는다
const listMailboxId = mailboxListIdOf(source)
if (source && listMailboxId !== null && (listMailboxId === mailboxId || listMailboxId === trashId)) {
void list.openList(source)
}
return true
+7 -4
View File
@@ -12,7 +12,7 @@ import { toApiError } from '@/composables/useApi'
import type { MailRequestOptions } from '@/composables/useMail'
import { SCHEDULED_COUNT_PAGE_SIZE } from '@/shared/constants/mail-send-later'
import type { MessageSummary, ScheduledMessageCancel } from '@/shared/types/mail'
import { sameListSource } from '@/shared/utils/mail-list-source'
import { mailboxListIdOf, sameListSource } from '@/shared/utils/mail-list-source'
import { messagePendingSend, pendingSendOf, samePendingSend } from '@/shared/utils/pending-send'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
@@ -83,7 +83,7 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
* 모두 알리지 않는다. 다 끝나면 돌아온다
*/
async function refreshPendingSends(): Promise<void> {
const includeSearch = list.messages.value.some((message) => messagePendingSend(message) !== null)
const includeSearch = list.listedSummaries.value.some((message) => messagePendingSend(message) !== null)
await Promise.all([list.syncVisibleList({ includeSearch }), refreshOpenedPendingSend(), loadScheduledCount()])
}
@@ -105,8 +105,11 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
}
// 기다리는 사이 세션이 바뀌었으면(다른 계정 — 메일 id 가 겹칠 수 있다) 목록 · 열린 메일을 건드리지 않는다 (스토어도 이미 비웠다)
if (sessionGeneration() !== session) return result
// 다른 목록으로 옮겨 갔으면 그 목록에는 이 메일이 없다 — 빼지 않고 서버 기준으로 맞추기만 한다
if (sameListSource(list.listSource.value, source) && (source?.kind === 'scheduled' || source?.kind === 'mailbox')) {
// 다른 목록으로 옮겨 갔으면 그 목록에는 이 메일이 없다 — 빼지 않고 서버 기준으로 맞추기만 한다 (대화로 묶은 메일함도 메일함이다 — 14단계)
if (
sameListSource(list.listSource.value, source) &&
(source?.kind === 'scheduled' || mailboxListIdOf(source) !== null)
) {
list.removeFromList(new Set([id]))
}
const current = opened.selected.value
+49 -15
View File
@@ -1,11 +1,14 @@
// 메일 스토어 모듈 — 고른 메일(여러 통 처리의 체크) (mail.store.ts 가 조립한다)
// 고를 수 있는 것은 불러온 메일까지다. 목록에서 빠진 메일은 고른 것에서도 빠지고(새 메일 확인으로 맞출 때 남은 메일의 선택은 그대로),
// 목록의 출처가 바뀌면(메일함을 바꾸거나 검색하면) 모두 푼다 (계약 2절 "여러 통 한꺼번에")
// 목록의 출처가 바뀌면(메일함을 바꾸거나 검색하면 · 대화로 묶기를 켜고 끄면) 모두 푼다 (계약 2절 "여러 통 한꺼번에")
// 아직 보내지 않은 메일(붙잡은 발송 — 보내는 중 · 예약, 9단계)은 고를 수 없다: 옮기거나 지워도 엔진은 그 시각에 보낸다 — 취소해야만 보낸편지함을 떠난다.
// 하나 고르기 · 범위 · 모두 고르기가 모두 건너뛰고, 고른 메일(checkedMessages — 여러 통 처리가 쓰는 것)에도 들지 않는다 (pending-send.ts 의 isHeldMessage)
// 대화로 묶은 메일함(14단계 — 계약 2절 "대화로 묶어 보기")은 줄(대화 id)을 고른다. 여러 통 처리는 고른 줄의 메일에서 붙잡힌 메일을 뺀 것에 한다
// (checkedActionIds), 줄의 메일이 모두 붙잡힌 메일이면 그 줄은 고를 수 없다 (mail-conversation-list.ts 의 isConversationHeld)
import { computed, shallowRef, watch } from 'vue'
import type { MessageSummary } from '@/shared/types/mail'
import type { ConversationSummary, MessageSummary } from '@/shared/types/mail'
import { conversationActionIdsOf, isConversationHeld } from '@/shared/utils/mail-conversation-list'
import {
checkAllState,
checkRange,
@@ -16,24 +19,44 @@ import {
import { isHeldMessage } from '@/shared/utils/pending-send'
import type { ListState } from './list'
export function createSelection(list: Pick<ListState, 'messages' | 'sourceSeq'>) {
/** 고른 메일 id — 바꿀 때는 새 모음으로 바꿔 끼운다 */
export function createSelection(
list: Pick<ListState, 'messages' | 'conversations' | 'isConversationList' | 'sourceSeq'>,
) {
/** 고른 줄 id(메일 목록은 메일 id, 대화 목록은 대화 id) — 바꿀 때는 새 모음으로 바꿔 끼운다 */
const checkedIds = shallowRef<ReadonlySet<string>>(new Set())
/** Shift + 클릭 범위의 기준 — 마지막으로 누른 메일 */
/** Shift + 클릭 범위의 기준 — 마지막으로 누른 줄 */
let checkAnchorId: string | null = null
/** 고를 수 있는 메일 id — 목록 순서 그대로, 붙잡은 발송은 뺀다 */
/** 고를 수 있는 줄 id — 목록 순서 그대로, 붙잡은 발송(대화 목록은 메일이 모두 붙잡힌 줄)은 뺀다 */
const selectableIds = computed<string[]>(() =>
list.messages.value.filter((message) => !isHeldMessage(message)).map((message) => message.id),
list.isConversationList.value
? list.conversations.value.filter((row) => !isConversationHeld(row)).map((row) => row.id)
: list.messages.value.filter((message) => !isHeldMessage(message)).map((message) => message.id),
)
/** 고른 줄 수 — 대화 목록은 대화 수 */
const checkedCount = computed<number>(() => checkedIds.value.size)
/** 고른 메일 — 목록 순서 그대로 (붙잡은 발송은 고른 것에 남아 있어도 넣지 않는다 — 여러 통 처리가 이것만 보낸다) */
/**
* 고른 메일 — 목록 순서 그대로 (붙잡은 발송은 고른 것에 남아 있어도 넣지 않는다 — 여러 통 처리가 이것만 보낸다).
* 대화 목록에서는 비어 있다 — 고른 대화(checkedConversations)와 그 메일(checkedActionIds)을 본다
*/
const checkedMessages = computed<MessageSummary[]>(() =>
checkedIds.value.size === 0
checkedIds.value.size === 0 || list.isConversationList.value
? []
: list.messages.value.filter((message) => checkedIds.value.has(message.id) && !isHeldMessage(message)),
)
/** 표 머리 고르기 상자의 상태 — 고를 수 있는 메일 기준 */
/** 고른 대화 — 목록 순서 그대로 (메일이 모두 붙잡힌 줄은 넣지 않는다). 메일 목록에서는 비어 있다 */
const checkedConversations = computed<ConversationSummary[]>(() =>
checkedIds.value.size === 0 || !list.isConversationList.value
? []
: list.conversations.value.filter((row) => checkedIds.value.has(row.id) && !isConversationHeld(row)),
)
/** 여러 통 처리가 보낼 메일 — 메일 목록은 고른 메일, 대화 목록은 고른 줄의 메일(붙잡힌 메일은 빼고 겹치지 않게) */
const checkedActionIds = computed<string[]>(() =>
list.isConversationList.value
? conversationActionIdsOf(checkedConversations.value)
: checkedMessages.value.map((message) => message.id),
)
/** 표 머리 고르기 상자의 상태 — 고를 수 있는 줄 기준 */
const checkState = computed<CheckAllState>(() => checkAllState(checkedIds.value, selectableIds.value))
function isSelectable(id: string): boolean {
@@ -45,27 +68,27 @@ export function createSelection(list: Pick<ListState, 'messages' | 'sourceSeq'>)
checkAnchorId = null
}
/** 하나 고르기 · 풀기 — 고르기 상자 · Ctrl(⌘) + 클릭. 고를 수 없는 메일이면 아무것도 하지 않는다 */
/** 하나 고르기 · 풀기 — 고르기 상자 · Ctrl(⌘) + 클릭. 고를 수 없는 줄이면 아무것도 하지 않는다 */
function toggleCheck(id: string) {
if (!isSelectable(id)) return
checkedIds.value = toggleChecked(checkedIds.value, id)
checkAnchorId = id
}
/** Shift + 클릭 — 마지막으로 누른 메일부터 이 메일까지 (고를 수 없는 메일은 건너뛴다) */
/** Shift + 클릭 — 마지막으로 누른 줄부터 이 줄까지 (고를 수 없는 줄은 건너뛴다) */
function checkRangeTo(id: string) {
if (!isSelectable(id)) return
checkedIds.value = checkRange(checkedIds.value, selectableIds.value, checkAnchorId, id)
checkAnchorId = id
}
/** 표 머리 상자 — 고를 수 있는 메일을 모두 고르거나(일부만 골랐어도) 모두 고른 상태면 푼다 */
/** 표 머리 상자 — 고를 수 있는 줄을 모두 고르거나(일부만 골랐어도) 모두 고른 상태면 푼다 */
function toggleCheckAll() {
if (checkState.value === 'all') clearChecked()
else checkedIds.value = new Set(selectableIds.value)
}
// 목록에서 빠진 메일 · 고를 수 없게 된 메일은 고른 것에서도 뺀다
// 목록에서 빠진 줄 · 고를 수 없게 된 줄은 고른 것에서도 뺀다
watch(
selectableIds,
(ids) => {
@@ -78,7 +101,18 @@ export function createSelection(list: Pick<ListState, 'messages' | 'sourceSeq'>)
// 다른 목록으로 바뀌면 모두 푼다
watch(list.sourceSeq, clearChecked, { flush: 'sync' })
return { checkedIds, checkedCount, checkedMessages, checkState, clearChecked, toggleCheck, checkRangeTo, toggleCheckAll }
return {
checkedIds,
checkedCount,
checkedMessages,
checkedConversations,
checkedActionIds,
checkState,
clearChecked,
toggleCheck,
checkRangeTo,
toggleCheckAll,
}
}
export type SelectionState = ReturnType<typeof createSelection>
+2 -1
View File
@@ -190,7 +190,8 @@
* 검색 칸 안의 [조건] 높이 32(--size-compact-control) ·
* 「조건으로 찾기」 패널의 칸(보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 날짜) · [조건 지우기] · [찾기] 높이 40(--size-filter-control) ·
* 결과 머리 아래 조건 줄 높이 40(--size-row-min) · 줄 안의 [조건 지우기] 36(--size-notice-action) ·
* 읽기 화면 "같은 대화" 칸에서 펼친 줄의 [답장] · [이 메일만 열기] 36(--size-notice-action — 캔버스 Thread14)
* 읽기 화면 "같은 대화" 칸에서 펼친 줄의 [답장] · [이 메일만 열기] 36(--size-notice-action — 캔버스 Thread14) ·
* 목록 머리의 [대화로 묶기] 높이 36(--size-notice-action — 캔버스 ListThreads14, 분할 보기는 아이콘만 36×36)
*/
--size-status-dot: 0.5rem; /* 8px — 설정 › 알림의 실시간 연결 점 */
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */