generated from ttipo/project-base
feat: 메일 14단계 — 자체 검색 색인 · 조건 검색 · 강조 · 대화 묶기 #18
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '(보낸 사람 없음)'
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 로 눌린다) */
|
||||
|
||||
Reference in New Issue
Block a user