generated from ttipo/project-base
feat: 메일 14단계 — 자체 검색 색인 · 조건 검색 · 강조 · 대화 묶기 #18
@@ -2,7 +2,8 @@
|
||||
// "같은 대화" 칸에서 펼친 줄의 안 (14단계 — 디자인 원본 Thread14 "하나 펼침")
|
||||
// 받는 사람 · 제목 한 줄씩, 본문(MailBodyFrame — 검색 쿼리 없이 받은 본문이라 강조가 없다), [답장] · [이 메일만 열기](36 — 밀도 예외 --size-notice-action 재사용).
|
||||
// - 본문이 다 보이지 않으면(서버의 isHtmlBodyOmitted · isBodyTruncated) 읽기 화면과 같은 안내 줄(MAIL_BODY_NOTICES)을 본문 위에 — 읽기 화면도 같은 본문이다.
|
||||
// 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다
|
||||
// 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다. 첨부가 있으면 본문 아래 · [답장] 위에
|
||||
// "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." (수는 읽기 화면의 "첨부 파일 N개"와 같다 — 본문 그림 빼고 · 대용량 첨부 더해)
|
||||
// - 본문을 받는 동안은 뼈대(aria-busy), 못 받으면 사전 문구 + [다시 시도]. 그사이 지워진 메일(RES_001)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 —
|
||||
// 스토어가 대화를 다시 받아 이 줄을 뺀다. 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다
|
||||
// - [답장]은 읽기 화면의 [답장]과 같은 쓰기 화면이고, 임시 저장본 · 예약한 메일에는 없다. [이 메일만 열기]는 그 메일을 읽기 화면으로 연다(mail-conversation.ts) —
|
||||
@@ -15,7 +16,9 @@ import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
|
||||
import { useMailPrint } from '@/composables/useMailPrint'
|
||||
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { listedAttachments } from '@/shared/utils/mail-attachment'
|
||||
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
|
||||
import type { ConversationRow } from '@/shared/utils/mail-conversation'
|
||||
import type { ConversationDetailState } from '@/stores/mail.store'
|
||||
@@ -47,6 +50,10 @@ const failure = computed(() => (props.detail?.status === 'error' ? props.detail.
|
||||
/** 그사이 지워진 메일 — 다시 해도 같고 그 메일로 갈 곳도 없다 */
|
||||
const missing = computed(() => failure.value?.code === 'RES_001')
|
||||
const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value) : []))
|
||||
/** 읽기 화면에서 볼 수 있는 첨부 수 — 본문 그림(isInline)은 빼고 대용량 첨부는 더한다 (MessageReader 의 첨부 칸과 같은 수) */
|
||||
const attachmentCount = computed(() =>
|
||||
ready.value ? listedAttachments(ready.value.attachments).length + (ready.value.largeFiles?.length ?? 0) : 0,
|
||||
)
|
||||
const bodyTitle = computed(() => MAIL_CONVERSATION_TEXT.bodyTitle(props.row.sender.name, props.row.time))
|
||||
|
||||
/** 본문 안에서 누른 키 — Ctrl+P(맥 ⌘P)면 브라우저 인쇄 대신 이 메일의 인쇄 화면 */
|
||||
@@ -97,6 +104,18 @@ function onFrameKeydown(event: KeyboardEvent) {
|
||||
:title="bodyTitle"
|
||||
@frame-keydown="onFrameKeydown"
|
||||
/>
|
||||
<p
|
||||
v-if="attachmentCount > 0"
|
||||
class="conversation-panel__attachments"
|
||||
data-conversation-attachments
|
||||
>
|
||||
<AppIcon
|
||||
name="paperclip"
|
||||
:size="14"
|
||||
class="conversation-panel__attachments-icon"
|
||||
/>
|
||||
<span>{{ MAIL_CONVERSATION_TEXT.attachmentsNote(formatCount(attachmentCount)) }}</span>
|
||||
</p>
|
||||
</template>
|
||||
<p
|
||||
v-else-if="failure"
|
||||
@@ -211,6 +230,24 @@ function onFrameKeydown(event: KeyboardEvent) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 첨부 안내 — 머리 줄(받는 사람 · 제목)과 같은 12.5px 회색, 아이콘은 첫 줄에 */
|
||||
.conversation-panel__attachments {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-6);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.conversation-panel__attachments-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.conversation-panel__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// 밀도 예외를 새 토큰 없이 다시 쓴다). 누르면 설정(groupByConversation)을 PATCH 한다 — 화면을 먼저 바꾸고(메일함 화면이 바로 묶거나 푼다)
|
||||
// 끝내 실패하면 서버가 확인한 값으로 되돌린다(mail-settings.store — 칸마다 저장은 한 번에 하나, 알림은 인터셉터).
|
||||
// 분할 보기의 목록 칸(384)은 머리가 좁아 아이콘만(36×36 — 이름은 title · 화면 낭독, MailboxEmptyButton 과 같은 결), 목록 보기는 아이콘 + 이름.
|
||||
// 이름까지 보이면 99px 이라 1440 분할 보기에서도 "받은편지함" 뒤 개수 글 자리가 11px 만 남는다(2026-11-06 쟀다) — 그래서 아이콘만 두고
|
||||
// 설정 › 보내기 · 읽기의 설명이 "말풍선 모양 [대화로 묶기] 단추"라고 모양을 말한다(MAIL_SETTINGS_TEXT.groupHint)
|
||||
// 켠 모양은 보기 전환의 고른 칸과 같다(청록 옅은 바탕 · 진한 청록 글자 — 테두리는 그대로, 보드의 #9FD3D7 테두리는 새 색이라 두지 않았다)
|
||||
import { computed } from 'vue'
|
||||
|
||||
|
||||
@@ -269,6 +269,7 @@ watch(
|
||||
<MailSearchEmpty
|
||||
v-else-if="mailStore.listStatus === 'ready' && search"
|
||||
:query="search.q"
|
||||
:has-conditions="searchConditions !== null"
|
||||
:offer-all="search.scope !== SEARCH_SCOPE_ALL && !searchTooLong"
|
||||
:word-limit="indexing && !searchTooLong"
|
||||
:too-many-words="searchTooLong"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// 목록 머리 — 메일함 이름 · 개수 · [대화로 묶기](14단계) · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
|
||||
// 개수(14단계 — 디자인 원본 ListThreads14): 묶지 않았으면 "메일 9통, 안 읽은 메일 2통"(메일함의 통 수), 묶었으면 "대화 6개, 안 읽은 메일 2통"
|
||||
// (대화 수는 목록의 total — 모르면 그 부분을 뺀다). 안 읽은 메일이 없으면 뒤를 뺀다.
|
||||
// 분할 보기(좁은 머리)는 안 읽은 메일이 있으면 "안 읽은 메일 2통"만, 없으면 "메일 9통"(묶었으면 "대화 6개")을 보이고 전체 글은 title · 화면 낭독에 둔다.
|
||||
// 칸 사이도 조금 좁힌다(이름↔개수 6 · 머리↔단추 4 · 단추 사이 4) — 1440 의 목록 칸(384)에서 "받은편지함" 뒤 "안 읽은 메일 99통"(86px)까지 다 보이게
|
||||
// (2026-11-06 실사용 시험 — 원래 자리 74px 로 "메일 6통, 안 …"처럼 안 읽은 수가 늘 잘렸다)
|
||||
// [대화로 묶기]는 메일함 목록(묶었든 아니든)에서만 — 검색 결과 · 예약됨은 묶지 않는다. 설정을 받는 동안은 숨기고(그사이 고른 것은 받기가 덮는다),
|
||||
// 받았거나 받지 못했으면 보인다 — 받지 못했으면 짐작 위에서 고르고 그 칸만 저장한다 (mail-settings.store — 리뷰 L4)
|
||||
// 예약됨(9단계)은 "예약됨" · "2통 · 보낼 시각 차례" (디자인 원본 MailSendLater)
|
||||
@@ -16,7 +19,7 @@ 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 { listHeadCountText } from '@/shared/utils/mail-conversation-list'
|
||||
import { listHeadCountText, listHeadShortCountText } 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'
|
||||
@@ -28,7 +31,7 @@ interface Props {
|
||||
variant?: 'split' | 'list'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'split',
|
||||
})
|
||||
|
||||
@@ -37,18 +40,25 @@ const settingsStore = useMailSettingsStore()
|
||||
|
||||
const scheduled = computed(() => isScheduledSource(mailStore.listSource))
|
||||
const title = computed(() => (scheduled.value ? SEND_LATER_TEXT.mailbox : (mailStore.currentMailbox?.name ?? '메일함')))
|
||||
/** 메일함 목록의 개수 칸 — 묶었으면 대화 수(목록의 total), 아니면 메일함의 통 수. 예약됨 · 메일함을 모르면 null */
|
||||
const countInput = computed(() => {
|
||||
const box = scheduled.value ? null : mailStore.currentMailbox
|
||||
if (!box) return null
|
||||
return mailStore.isConversationList
|
||||
? { grouped: true, total: mailStore.pageInfo?.total ?? null, unread: box.unreadCount }
|
||||
: { grouped: false, total: box.totalCount, unread: box.unreadCount }
|
||||
})
|
||||
const countText = computed(() => {
|
||||
if (scheduled.value) {
|
||||
if (mailStore.listStatus !== 'ready') return ''
|
||||
return SEND_LATER_TEXT.listCount(formatCount(mailStore.pageInfo?.total ?? mailStore.messages.length))
|
||||
}
|
||||
const box = mailStore.currentMailbox
|
||||
if (!box) return ''
|
||||
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 })
|
||||
return countInput.value ? listHeadCountText(countInput.value) : ''
|
||||
})
|
||||
/** 보이는 개수 — 분할 보기(좁은 머리)의 메일함 목록은 줄인 글(안 읽은 메일이 있으면 그 수만), 그 밖은 전체 글 */
|
||||
const shownCountText = computed(() =>
|
||||
props.variant === 'split' && countInput.value ? listHeadShortCountText(countInput.value) : countText.value,
|
||||
)
|
||||
/** [대화로 묶기] — 메일함 목록에서만, 설정을 받았거나 받지 못했을 때 (14단계 — 받는 중 · 받기 전에는 숨긴다) */
|
||||
const groupable = computed(
|
||||
() =>
|
||||
@@ -72,11 +82,19 @@ const emptiable = computed(() => {
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<!-- 분할 보기 칸에서는 줄어 잘릴 수 있다 — 마우스를 올리면 다 보인다 (글은 그대로 있어 화면 낭독은 다 읽는다) -->
|
||||
<!--
|
||||
분할 보기는 줄인 글을 보이고 전체 글은 title · 화면 낭독에 둔다. 그래도 좁으면 줄어 잘릴 수 있다 — 마우스를 올리면 다 보인다
|
||||
-->
|
||||
<span
|
||||
class="list-header__count"
|
||||
:title="countText || undefined"
|
||||
>{{ countText }}</span>
|
||||
>
|
||||
<template v-if="shownCountText !== countText">
|
||||
<span aria-hidden="true">{{ shownCountText }}</span>
|
||||
<span class="visually-hidden">{{ countText }}</span>
|
||||
</template>
|
||||
<template v-else>{{ countText }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="list-header__end">
|
||||
<MailConversationToggle
|
||||
@@ -118,6 +136,19 @@ const emptiable = computed(() => {
|
||||
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
|
||||
}
|
||||
|
||||
/* 분할 보기의 목록 칸(384) — 개수 글이 들 자리를 내려고 칸 사이를 조금 좁힌다 (머리말) */
|
||||
.list-header--split {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.list-header--split .list-header__heading {
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.list-header--split .list-header__end {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/*
|
||||
* 이름 · 개수 — 오른쪽 단추들이 남긴 자리를 쓴다. 좁으면 개수가 먼저 줄고(사라질 때까지) 그다음에 이름이 준다
|
||||
* (14단계 — [대화로 묶기]가 더해져 분할 보기 칸에서 이름이 1px 모자라 "색인 …"으로 잘렸다)
|
||||
|
||||
@@ -248,6 +248,13 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디
|
||||
expect(panel.text()).toContain(
|
||||
'검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
|
||||
)
|
||||
// 칸 안내 — 내용 칸은 칸 이름과 같은 말로 (2026-11-06 실사용 시험)
|
||||
expect(['from', 'to', 'subject', 'body'].map((name) => field(wrapper, name).attributes('placeholder'))).toEqual([
|
||||
'이름이나 메일 주소',
|
||||
'이름이나 메일 주소',
|
||||
'제목에 든 말',
|
||||
'내용에 든 말',
|
||||
])
|
||||
expect(document.activeElement).toBe(field(wrapper, 'from').element)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 조건이 걸린 검색 결과의 조건 줄 (디자인 원본 Search14 "조건으로 찾음") — 찾을 곳 줄 아래 한 줄(최소 40)
|
||||
// "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기](36 — 안내 줄 단추의 밀도 예외 --size-notice-action).
|
||||
// 조건은 쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다. 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(mail-search.ts)
|
||||
// 조건은 쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다. 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(하루면 "… 하루"),
|
||||
// 보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" (mail-search.ts)
|
||||
// [조건 지우기]는 조건만 빼고 다시 찾는다 — 검색어도 없으면 검색을 닫는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -9,6 +10,7 @@ import { useClock } from '@/composables/useClock'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { SEARCH_CONDITION_LINE_TEXT } from '@/shared/constants/mail-search'
|
||||
import { searchConditionsSummary, type SearchConditions } from '@/shared/utils/mail-search'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
interface Props {
|
||||
conditions: SearchConditions
|
||||
@@ -17,9 +19,10 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const clock = useClock()
|
||||
const authStore = useAuthStore()
|
||||
const { clearConditions } = useMailSearchNavigation()
|
||||
|
||||
const summary = computed(() => searchConditionsSummary(props.conditions, new Date(clock.now())))
|
||||
const summary = computed(() => searchConditionsSummary(props.conditions, new Date(clock.now()), authStore.user))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 없음 (디자인 원본 Search · Search14 "결과 없음") — 더 찾을 것이 없을 때(hasNext false)만 보인다(이어 찾을 것이 남았으면 MailSearchMore)
|
||||
// 검색 아이콘 · "‘계약’에 맞는 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · "다른 말로 찾거나 조건을 줄여 보세요."
|
||||
// 검색 아이콘 · "‘계약’에 맞는 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 —
|
||||
// 검색어와 조건이 함께면 "다른 말로 찾거나 조건을 줄여 보세요.", 조건만이면 "조건을 바꾸거나 줄여 보세요.", 걸린 조건이 없으면 "다른 말로 찾아 보세요."
|
||||
// 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 낱말 단위)은 그 한계를 한 줄 더한다 — 준비가 끝나면 뺀다 (14단계)
|
||||
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
|
||||
// 검색어와 조건의 낱말이 한도를 넘는 주소(손으로 고친 주소 — 14단계)면 찾지 않았다 — "찾을 말이 너무 많습니다"와 줄이는 방법 (tooManyWords)
|
||||
@@ -13,6 +14,8 @@ import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
|
||||
interface Props {
|
||||
/** 검색어 — 조건만으로 찾았으면 빈 글 */
|
||||
query: string
|
||||
/** 걸린 조건이 있다 — 없으면 조건을 줄이라고 하지 않는다 */
|
||||
hasConditions?: boolean
|
||||
/** 한 메일함에서 찾았다 — 전체 메일함에서 찾기를 권한다 */
|
||||
offerAll: boolean
|
||||
/** 낱말 단위로 찾는 동안(지난 메일 색인을 준비하는 중)이다 — 그 한계를 알린다 */
|
||||
@@ -26,6 +29,7 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hasConditions: false,
|
||||
wordLimit: false,
|
||||
tooManyWords: false,
|
||||
})
|
||||
@@ -36,7 +40,8 @@ const title = computed(() =>
|
||||
)
|
||||
const hint = computed(() => {
|
||||
if (props.tooManyWords) return SEARCH_NOTICE_TEXT.tooManyWordsHint(MAIL_SEARCH_MAX_WORDS)
|
||||
return props.query ? MAIL_SEARCH_TEXT.emptyHint : MAIL_SEARCH_TEXT.emptyConditionsHint
|
||||
if (!props.query) return MAIL_SEARCH_TEXT.emptyConditionsHint
|
||||
return props.hasConditions ? MAIL_SEARCH_TEXT.emptyHint : MAIL_SEARCH_TEXT.emptyWordsHint
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
expect(store.checkedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 알린다', async () => {
|
||||
it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 그 메일함 이름으로 알린다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m3')
|
||||
@@ -186,6 +186,31 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm3'], mailboxId: 'inbox' })
|
||||
// 한 메일함으로 되돌렸다 — 어디로 되돌렸는지 말한다 (2026-11-06 실사용 시험)
|
||||
expect(toastTexts()).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('여러 메일함으로 되돌렸으면 메일함 이름 없이 "메일 N통을 되돌렸습니다."', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
messages: [summary('m1'), summary('m2', { mailboxIds: ['sent'] }), summary('m3')],
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="move"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보관함')?.trigger('click')
|
||||
await flushPromises()
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 2통을 보관함으로 옮겼습니다.')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches().slice(1)).toEqual([
|
||||
{ ids: ['m1'], mailboxId: 'inbox' },
|
||||
{ ids: ['m2'], mailboxId: 'sent' },
|
||||
])
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
@@ -398,7 +423,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], mailboxId: 'inbox', isJunk: false })
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
expect(toastTexts()).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "스팸이 아닌 메일로 표시해 …" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
|
||||
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
|
||||
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
|
||||
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다
|
||||
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다.
|
||||
// 분할 보기는 보이는 글만 "N개 선택됨"으로 줄이고 화면 낭독은 "대화 N개 선택됨"을 읽는다 — 목록 칸(384)에서 단추 일곱(280)과 한 줄에 들게
|
||||
// (2026-11-06 실사용 시험: "대화 1개 선택됨" 88px 이면 도구가 두 줄 · 97px 로 넘쳤다. "N개 선택됨"은 세 자리까지 79px — 1440 의 자리 83px)
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
|
||||
@@ -76,6 +78,12 @@ const countText = computed(() => {
|
||||
const count = formatCount(mailStore.checkedCount)
|
||||
return mailStore.isConversationList ? MAIL_CONVERSATION_LIST_TEXT.selected(count) : MAIL_BULK_TEXT.selected(count)
|
||||
})
|
||||
/** 분할 보기에서 보이는 고른 수 — 대화 목록만 줄인다("1개 선택됨"). 줄이지 않으면 null (화면 낭독은 늘 countText) */
|
||||
const shortCountText = computed(() =>
|
||||
props.variant === 'split' && mailStore.isConversationList
|
||||
? MAIL_CONVERSATION_LIST_TEXT.selectedShort(formatCount(mailStore.checkedCount))
|
||||
: null,
|
||||
)
|
||||
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[]>(() => [
|
||||
@@ -167,7 +175,13 @@ watch(disabledTools, (disabled) => {
|
||||
:id="titleId"
|
||||
class="bulk-bar__count"
|
||||
>
|
||||
{{ countText }}
|
||||
<template v-if="shortCountText">
|
||||
<span aria-hidden="true">{{ shortCountText }}</span>
|
||||
<span class="visually-hidden">{{ countText }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ countText }}
|
||||
</template>
|
||||
</h1>
|
||||
<div
|
||||
ref="toolsRef"
|
||||
|
||||
@@ -291,6 +291,42 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('줄의 차례 · 미리 보기 (2026-11-06 실사용 시험)', () => {
|
||||
it('보내기를 기다리는 메일은 받은 줄의 맨 끝 — 서버가 저장한 시각 차례로 주어도 그 뒤에 온 답장보다 앞에 서지 않는다', async () => {
|
||||
const sendAt = new Date(2026, 10, 6, 23, 38).toISOString()
|
||||
const scheduled = summary('s1', {
|
||||
mailboxIds: ['sent'],
|
||||
from: USER,
|
||||
receivedAt: at(2026, 9, 29, 10, 0),
|
||||
pendingSend: { kind: 'scheduled', sendAt },
|
||||
})
|
||||
serve({ conversation: () => page([ORIGINAL, scheduled, REPLY, CURRENT]) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual([
|
||||
'm1',
|
||||
'm2',
|
||||
'm3',
|
||||
's1',
|
||||
])
|
||||
})
|
||||
|
||||
it('미리 보기에서 우리 원문 인용("> 원본 메일")부터 뒤는 뺀다 — 남는 글이 없으면 제목', async () => {
|
||||
serve({
|
||||
conversation: () =>
|
||||
page([
|
||||
{ ...ORIGINAL, preview: '도윤님, 금요일까지 정하겠습니다. > 원본 메일 > 보낸 사람: 이도윤' },
|
||||
{ ...REPLY, preview: '> 원본 메일 > 보낸 사람: 김아라 > 날짜: 2026. 10. 27.' },
|
||||
CURRENT,
|
||||
]),
|
||||
})
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(rowOf(wrapper, 'm1').get('.conversation-row__preview').text()).toBe('도윤님, 금요일까지 정하겠습니다.')
|
||||
expect(rowOf(wrapper, 'm2').get('.conversation-row__preview').text()).toBe(REPLY.subject)
|
||||
})
|
||||
})
|
||||
|
||||
describe('펼치기 · 접기', () => {
|
||||
it('다른 줄을 누르면 그 자리에서 펼친다 — 받는 사람 · 제목 · 본문(강조 쿼리 없이) · [답장] · [이 메일만 열기], 안 읽은 메일이면 읽음으로', async () => {
|
||||
serve()
|
||||
@@ -363,6 +399,40 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
|
||||
expect(rowOf(wrapper, 's1').get('[data-conversation-open]').attributes('href')).toBe('/mail/sent/s1')
|
||||
})
|
||||
|
||||
it('첨부가 있으면 본문 아래 · [답장] 위에 "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." — 본문 그림은 세지 않는다', async () => {
|
||||
serve({
|
||||
detail: (request) => {
|
||||
const id = request.url.split('/').pop() ?? ''
|
||||
const base = THREAD.find((item) => item.id === id) ?? summary(id)
|
||||
if (id !== 'm2') return ok(detail(base))
|
||||
return ok(
|
||||
detail(base, {
|
||||
attachments: [
|
||||
{ id: 'a1', name: '견적_수정안.csv', type: 'text/csv', size: 44 },
|
||||
{ id: 'a2', name: '단가표.xlsx', type: 'application/vnd.ms-excel', size: 9_000 },
|
||||
{ id: 'i1', name: 'logo.png', type: 'image/png', size: 1_200, isInline: true },
|
||||
],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
await rowOf(wrapper, 'm2').get('button').trigger('click')
|
||||
await rowOf(wrapper, 'm1').get('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const panel = rowOf(wrapper, 'm2').get('.conversation-panel')
|
||||
const note = panel.get('[data-conversation-attachments]')
|
||||
expect(note.text()).toBe('첨부 파일 2개는 [이 메일만 열기]를 누르면 볼 수 있습니다.')
|
||||
// 본문 아래 · [답장] 위
|
||||
const order = [...panel.element.children].map((child) => child.className.split(' ')[0])
|
||||
expect(order.indexOf('conversation-panel__attachments')).toBe(order.indexOf('mail-body-frame') + 1)
|
||||
expect(order.indexOf('conversation-panel__actions')).toBe(order.indexOf('conversation-panel__attachments') + 1)
|
||||
// 첨부가 없는 메일에는 없다
|
||||
expect(rowOf(wrapper, 'm1').find('[data-conversation-attachments]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('본문을 못 받으면 줄 안에 사전 문구 + [다시 시도] — 알리지 않는다', async () => {
|
||||
let failing = true
|
||||
serve({ detail: (request) => (failing ? fail(503, 'MAIL_001') : ok(detail(summary(request.url.split('/').pop() ?? '')))) })
|
||||
|
||||
@@ -380,7 +380,7 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.method === 'PATCH').at(-1)?.data).toEqual({ ids: ['m1'], mailboxId: 'inbox' })
|
||||
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 1통을 되돌렸습니다.')
|
||||
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 1통을 받은편지함으로 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('목록 보기에서는 지우면 목록으로 돌아간다', async () => {
|
||||
|
||||
@@ -288,11 +288,11 @@ describe('MessageReader — 9단계', () => {
|
||||
describe('예약한 메일 — 청록 안내 줄 · 답장 · 전달 · 스팸 신고 없음 · 삭제는 예약 취소 뒤 휴지통 (디자인 원본 MailSendLater)', () => {
|
||||
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
|
||||
|
||||
it('머리 아래 "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
|
||||
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
|
||||
const { wrapper } = await mountWith({ message: SCHEDULED, source: { kind: 'scheduled' } })
|
||||
const notice = wrapper.get('[data-notice="scheduled"]')
|
||||
|
||||
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)} 에 보낼 메일입니다.`)
|
||||
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)}에 보낼 메일입니다.`)
|
||||
expect(notice.findAll('button').map((button) => button.text())).toEqual(['고치기', '예약 취소'])
|
||||
expect(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual(['삭제'])
|
||||
expect(wrapper.find('[data-reader-more]').exists()).toBe(true)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// — tokens.css 의 --table-* 값. 머리 줄은 MessageTableHead, 행은 MessageTableRow.
|
||||
// 머리 · 상태 · 메일 더 보기 · 표 위치 기억(메일을 열었다 돌아올 때)은 목록 틀(MailListFrame)이 맡는다.
|
||||
// 대화로 묶은 메일함(14단계 — 보드 ListThreads14 에 표가 없어 정했다)은 한 줄이 대화 하나다(ConversationTableRow):
|
||||
// 고르기 · 별 · 보낸 사람(참여자 + 통 수) · 제목 · 날짜 — 크기 칸은 없다(대화의 크기는 마지막 메일의 크기가 아니다)
|
||||
// 고르기 · 별 · 참여자(+ 통 수 — 칸 머리도 "참여자") · 제목 · 날짜 — 크기 칸은 없다(대화의 크기는 마지막 메일의 크기가 아니다)
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
@@ -16,7 +16,6 @@ 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()
|
||||
@@ -24,15 +23,15 @@ const mailStore = useMailStore()
|
||||
const rows = useMailListRows()
|
||||
const conversationRows = useConversationRows()
|
||||
|
||||
/** 대화로 묶은 메일함 — 크기 칸이 없고 보낸 사람 칸에 참여자를 보인다 */
|
||||
/** 대화로 묶은 메일함 — 크기 칸이 없고 사람 칸에 참여자를 보인다 */
|
||||
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))
|
||||
/** 사람 칸 머리 — 대화는 줄이 참여자라 어느 메일함이든 "참여자", 메일은 "보낸 사람"(보낸편지함 · 임시보관함은 "받는 사람") */
|
||||
const whoHeader = computed(() => (threads.value ? MAIL_CONVERSATION_LIST_TEXT.participantsColumn : rows.whoHeader.value))
|
||||
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
|
||||
@@ -13,7 +13,8 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
|
||||
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
|
||||
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
|
||||
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
|
||||
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때
|
||||
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때.
|
||||
// 옮기기 · 삭제 · 스팸 알림은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 "4통"은 대화 전체라서 (2026-11-06 실사용 시험)
|
||||
|
||||
export function useBulkMailActions() {
|
||||
const mailStore = useMailStore()
|
||||
@@ -64,11 +65,13 @@ export function useBulkMailActions() {
|
||||
|
||||
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
// 대화로 묶은 목록은 줄의 통 수가 대화 전체라 알림이 지금 메일함의 메일임을 밝힌다 — 시작할 때의 메일함 (기다리는 사이 목록이 바뀔 수 있다)
|
||||
const from = mailStore.isConversationList ? (mailStore.currentMailbox?.name ?? null) : null
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveChecked(mailbox.id, options)
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox.name)
|
||||
feedback.announceMove(result, mailbox.name, { from })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
@@ -6,14 +6,17 @@ import {
|
||||
purgedMessageText,
|
||||
restoredMessageText,
|
||||
type BulkOrigin,
|
||||
type MovedTextOptions,
|
||||
} from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문") — 도구 줄과 읽기 화면의 「삭제」 · 「스팸 신고」가 함께 쓴다.
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
|
||||
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 받은편지함으로 옮겼습니다 (스팸 아님)."
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고, 한 메일함으로 되돌렸으면 "메일 N통을 X으로 되돌렸습니다.",
|
||||
* 여러 곳이면 "메일 N통을 되돌렸습니다.")
|
||||
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다."
|
||||
* — 스팸 신고 · 스팸 아님의 되돌리기는 원래 메일함으로 옮기며 표시도 되돌린다(보낸 값의 반대)
|
||||
* - 대화로 묶은 목록에서 줄에 한 일(14단계)은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 통 수는 대화 전체라서 (options.from)
|
||||
* - "메일 N통을 영구 삭제했습니다."
|
||||
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
|
||||
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
|
||||
@@ -23,21 +26,31 @@ export function useMailMoveFeedback() {
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** 되돌린 메일함의 이름 — 한 메일함으로 되돌릴 때만(여러 곳이면 null), 메일함 목록에 없으면 null */
|
||||
function restoredPlaceOf(origins: readonly BulkOrigin[]): string | null {
|
||||
if (origins.length !== 1) return null
|
||||
const mailboxId = origins[0]?.mailboxId
|
||||
return mailStore.mailboxes.find((mailbox) => mailbox.id === mailboxId)?.name ?? null
|
||||
}
|
||||
|
||||
async function undo(origins: readonly BulkOrigin[], options: MoveOptions): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins, options)
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped, restoredPlaceOf(origins)))
|
||||
}
|
||||
|
||||
function announceMove(result: BulkMoveResult, mailboxName: string) {
|
||||
/** textOptions.from — 대화로 묶은 목록에서 줄에 한 일이면 그 목록의 메일함 이름 ("받은편지함에 있는 메일 2통을 …") */
|
||||
function announceMove(result: BulkMoveResult, mailboxName: string, textOptions: MovedTextOptions = {}) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done === 0 && skipped === 0) return
|
||||
const origins = result.origins
|
||||
const undoOptions: MoveOptions = result.isJunk === undefined ? {} : { isJunk: !result.isJunk }
|
||||
const text =
|
||||
result.isJunk === false ? notSpamMovedText(done, mailboxName, skipped) : movedMessageText(done, mailboxName, skipped)
|
||||
result.isJunk === false
|
||||
? notSpamMovedText(done, mailboxName, skipped, textOptions)
|
||||
: movedMessageText(done, mailboxName, skipped, textOptions)
|
||||
toast.success(
|
||||
text,
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins, undoOptions) } : undefined,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
|
||||
import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
|
||||
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
|
||||
import type { MailSettings } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.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단계 — 2026-11-06 실사용 시험: 다른 탭에서 끈 묶기가 열어 둔 탭에 남았다)
|
||||
// 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const SETTINGS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
|
||||
|
||||
describe('useMailSettingsRefresh (탭으로 돌아오면 설정 다시 받기)', () => {
|
||||
let scope: EffectScope
|
||||
let clock: ManualClock
|
||||
let visibility: DocumentVisibilityState
|
||||
let server: MailSettings
|
||||
let load: () => MockResult
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
scope = effectScope()
|
||||
clock = createManualClock()
|
||||
visibility = 'visible'
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
|
||||
server = SETTINGS
|
||||
load = () => ok(server)
|
||||
requests = installMockApi((request) => (request.method === 'GET' ? load() : ok(server)))
|
||||
// 메일 화면에 들어올 때 받아 둔다 (MailLayout 의 ensureLoaded)
|
||||
await useMailSettingsStore().load()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
Reflect.deleteProperty(document, 'visibilityState')
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function start() {
|
||||
scope.run(() => useMailSettingsRefresh({ clock }))
|
||||
}
|
||||
|
||||
async function comeBack(afterMs: number) {
|
||||
visibility = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
clock.advance(afterMs)
|
||||
visibility = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const fetched = () => requests.filter((request) => request.method === 'GET' && request.url === '/mail-settings').length
|
||||
|
||||
it('마지막으로 받은 지 60초가 지난 뒤 탭으로 돌아오면 조용히 다시 받아 그 값을 쓴다 — 묶기 · 보내기 취소 초 · 외부 이미지', async () => {
|
||||
start()
|
||||
server = { undoSendSeconds: 30, externalImages: 'always', groupByConversation: false }
|
||||
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
|
||||
expect(fetched()).toBe(2)
|
||||
expect(requests.at(-1)?.config.silent).toBe(true)
|
||||
const store = useMailSettingsStore()
|
||||
expect(store.groupByConversation).toBe(false)
|
||||
expect(store.knownUndoSendSeconds).toBe(30)
|
||||
expect(store.settings?.externalImages).toBe('always')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('60초가 안 됐으면 다시 받지 않는다 — 받은 뒤에는 또 60초를 센다', async () => {
|
||||
start()
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
|
||||
expect(fetched()).toBe(1)
|
||||
|
||||
await comeBack(1)
|
||||
expect(fetched()).toBe(2)
|
||||
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
|
||||
expect(fetched()).toBe(2)
|
||||
})
|
||||
|
||||
it('숨는 때에는 받지 않는다 — 다시 보일 때만', async () => {
|
||||
start()
|
||||
clock.advance(MAIL_SETTINGS_REFRESH_AFTER_MS * 2)
|
||||
visibility = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await flushPromises()
|
||||
|
||||
expect(fetched()).toBe(1)
|
||||
})
|
||||
|
||||
it('세션을 쓸 수 없으면(제한된 세션 · 로그아웃) 받지 않는다', async () => {
|
||||
start()
|
||||
// 같은 직원의 세션에 제한이 붙었다 — 받아 둔 설정은 그대로지만 묻지 않는다
|
||||
useAuthStore().markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(useMailSettingsStore().status).toBe('ready')
|
||||
expect(fetched()).toBe(1)
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(fetched()).toBe(1)
|
||||
})
|
||||
|
||||
it('받지 못하면 알리지 않고 지금 값을 둔다 — 60초 뒤 다시 보일 때 또 해 본다', async () => {
|
||||
start()
|
||||
load = () => fail(503, 'SYS_001')
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
|
||||
expect(fetched()).toBe(2)
|
||||
expect(useMailSettingsStore().settings).toEqual(SETTINGS)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
load = () => ok({ ...SETTINGS, groupByConversation: false })
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(fetched()).toBe(3)
|
||||
expect(useMailSettingsStore().groupByConversation).toBe(false)
|
||||
})
|
||||
|
||||
it('화면이 사라지면 그만 본다', async () => {
|
||||
start()
|
||||
scope.stop()
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(fetched()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { onScopeDispose, watch } from 'vue'
|
||||
|
||||
import { systemClock, type Clock } from './useClock'
|
||||
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
|
||||
// 보내기 · 읽기 설정을 메일 화면에서 다시 받는 때 (14단계 — 2026-11-06 실사용 시험: 다른 탭에서 끈 [대화로 묶기]가 열어 둔 탭에 남았다)
|
||||
// - 메일 화면(MailLayout)이 있는 동안, 탭이 다시 보이게 될 때 설정을 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다(알리지 않는다)
|
||||
// — 받은 묶기 값이 다르면 메일함 화면이 그 값으로 목록을 다시 열고(MailboxPage), 보내기 취소 초 · 외부 이미지도 받은 값을 쓴다
|
||||
// - 마지막으로 받은 때는 화면에 들어온 때(그때 받아 두었거나 지금 받는다)와 받을 때마다(스토어의 fetchSeq)다. 다시 받기를 시작하면 그때로 —
|
||||
// 받지 못하면(알리지 않는다) 60초 뒤 다시 보일 때 또 해 본다
|
||||
// - 세션이 끝났으면(로그아웃 · 세션 만료 · 제한된 세션) 받지 않는다. 저장하는 중 · 받는 사이 고른 값은 스토어가 지킨다(refresh)
|
||||
|
||||
export interface MailSettingsRefreshOptions {
|
||||
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
function isPageVisible(): boolean {
|
||||
return typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||
}
|
||||
|
||||
export function useMailSettingsRefresh(options: MailSettingsRefreshOptions = {}): void {
|
||||
const clock = options.clock ?? systemClock
|
||||
const authStore = useAuthStore()
|
||||
const settingsStore = useMailSettingsStore()
|
||||
|
||||
/** 설정을 마지막으로 받은 때 (ms) */
|
||||
let fetchedAt = clock.now()
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (!isPageVisible() || !authStore.canUseApp) return
|
||||
const now = clock.now()
|
||||
if (now - fetchedAt < MAIL_SETTINGS_REFRESH_AFTER_MS) return
|
||||
fetchedAt = now
|
||||
void settingsStore.refresh()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => settingsStore.fetchSeq,
|
||||
() => {
|
||||
fetchedAt = clock.now()
|
||||
},
|
||||
)
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
onScopeDispose(() => document.removeEventListener('visibilitychange', onVisibilityChange))
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
|
||||
expect(wrapper.find('#compose-recipient-error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’ 에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
|
||||
it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
|
||||
const preset = schedulePresets(NOW)[0]
|
||||
if (!preset) throw new Error('고르기가 없다')
|
||||
const sendAt = sendAtOf(preset.at)
|
||||
@@ -344,7 +344,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
|
||||
expect(toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'success',
|
||||
message: `${formatScheduleTime(sendAt)} 에 보냅니다.`,
|
||||
message: `${formatScheduleTime(sendAt)}에 보냅니다.`,
|
||||
action: { label: '예약됨 보기', to: { name: 'mail-scheduled' } },
|
||||
expiresAt: null,
|
||||
}),
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// 들어올 때 예약한 메일 수(패널의 「예약됨」) · 차단 목록(읽기 화면 ⋯ 의 차단 · 스팸메일함의 안내 줄)을 받아 둔다 (9단계 — 못 받으면 알리지 않는다).
|
||||
// 예약한 메일 수는 새 메일 확인 · 실시간 알림이 바뀜을 알릴 때 다시 묻는다 (계약 2절 "보내기 취소 · 예약 발송").
|
||||
// 엔진이 붙잡은 발송을 풀 때(보낼 시각)는 새 메일 확인이 알아채지 못한다 — 그 시각이 지나면 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
|
||||
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다)
|
||||
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다).
|
||||
// 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 설정을 조용히 다시 받는다 — 다른 탭에서 바꾼 묶기가 남지 않게 (useMailSettingsRefresh, 14단계)
|
||||
// 회사가 2단계 인증을 필수로 정한 유예 기간이면 상단 바 바로 아래에 노란 줄 (13단계 — TwoFactorGraceNotice)
|
||||
import { provide } from 'vue'
|
||||
|
||||
@@ -19,6 +20,7 @@ import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
import TwoFactorGraceNotice from '@/components/mail/TwoFactorGraceNotice.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
|
||||
import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
|
||||
@@ -44,8 +46,10 @@ void useMailVacationStore().load()
|
||||
void mailStore.loadScheduledCount()
|
||||
// 읽기 화면 ⋯ 「보낸 사람 차단」 · 「차단 풀기」와 스팸메일함의 "차단한 보낸 사람의 메일" 줄
|
||||
useMailBlockStore().ensureLoaded()
|
||||
// 쓰기 화면의 [보내기 취소] 알림 마감 — 설정한 초 (모르면 서버가 준 보낼 시각으로 센다)
|
||||
// 쓰기 화면의 [보내기 취소] 알림 마감 — 설정한 초 (모르면 서버가 준 보낼 시각으로 센다). 메일함 목록의 묶기도 이 설정이다
|
||||
useMailSettingsStore().ensureLoaded()
|
||||
// 탭이 다시 보일 때 — 설정을 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다 (다른 탭 · PC 에서 바꾼 값)
|
||||
useMailSettingsRefresh({ clock })
|
||||
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다).
|
||||
// 숨은 탭은 받은편지함의 안 읽은 수만 고쳐 탭 제목 "(N) " 을 맞추고, 다시 보일 때 맞춘다
|
||||
|
||||
@@ -162,7 +162,7 @@ describe('예약됨 · 보낸편지함 (9단계)', () => {
|
||||
|
||||
const opened = await mountAt('/mail/scheduled/s1')
|
||||
expect(opened.wrapper.find('.scheduled-row').exists()).toBe(false)
|
||||
expect(opened.wrapper.get('[data-notice="scheduled"]').text()).toContain(`${formatScheduleTime(FIRST_SEND_AT)} 에 보낼 메일입니다.`)
|
||||
expect(opened.wrapper.get('[data-notice="scheduled"]').text()).toContain(`${formatScheduleTime(FIRST_SEND_AT)}에 보낼 메일입니다.`)
|
||||
expect(opened.wrapper.get('.reader').text()).toContain('목록으로')
|
||||
})
|
||||
|
||||
|
||||
@@ -121,13 +121,15 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('결과가 없으면 "다른 말로 찾거나 조건을 줄여 보세요."와 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
|
||||
it('결과가 없으면 "다른 말로 찾아 보세요."(걸린 조건이 없다)와 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
|
||||
results = []
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
|
||||
// 걸린 조건이 없으면 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
|
||||
expect(wrapper.text()).toContain('다른 말로 찾아 보세요.')
|
||||
expect(wrapper.text()).not.toContain('조건을 줄여')
|
||||
// 색인이 준비됐으면 낱말 단위의 한계 안내 · 검색 준비 중 줄은 없다 (14단계)
|
||||
expect(wrapper.find('[data-search-word-limit]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-notice="indexing"]').exists()).toBe(false)
|
||||
@@ -326,6 +328,24 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
|
||||
expect(wrapper.text()).toContain('조건을 바꾸거나 줄여 보세요.')
|
||||
})
|
||||
|
||||
it('검색어와 조건으로 찾아 결과가 없으면 "다른 말로 찾거나 조건을 줄여 보세요."', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
|
||||
})
|
||||
|
||||
it('조건 줄 — 보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일", 하루만 고른 기간은 "… 하루"', async () => {
|
||||
const mine = `from=${encodeURIComponent('김아라')}&to=${encodeURIComponent(USER.email.toUpperCase())}&since=2026-11-06&until=2026-11-06`
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${mine}`)
|
||||
|
||||
expect(wrapper.get('[data-search-condition-line]').text()).toContain(
|
||||
'조건: 내가 보낸 메일, 내가 받은 메일, 2026. 11. 6. 하루',
|
||||
)
|
||||
})
|
||||
|
||||
it('total 이 null 이면 통 수를 보이지 않는다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index'
|
||||
@@ -363,20 +383,22 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
|
||||
expect(row.findAll('mark')).toHaveLength(0)
|
||||
expect(row.get('[data-row-preview]').text()).toBe('지난번 말씀하신 계약서 초안을 첨부했습니다.')
|
||||
const notice = wrapper.get('[data-search-notice="indexing"]')
|
||||
// 색인은 밤에만 채운다 — 낮 내내 같은 글이어도 멈춘 줄 알지 않게 (2026-11-06 실사용 시험)
|
||||
expect(notice.text()).toBe(
|
||||
'지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다.',
|
||||
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
|
||||
)
|
||||
expect(notice.element.closest('[role="status"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('색인을 준비하는 중의 결과 없음에는 낱말 단위의 한계 안내를 더한다', async () => {
|
||||
it('색인을 준비하는 중의 결과 없음에는 찾지 못하는 예를 더한다 — 위 "검색 준비 중" 줄과 같은 말을 되풀이하지 않는다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? indexState('building') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.get('[data-search-word-limit]').text()).toBe(
|
||||
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
'준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('통째로')
|
||||
})
|
||||
|
||||
it('색인 상태를 못 받으면 안내 줄 · 한계 안내 없이 지금처럼 — 알리지 않는다', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { routes } from '@/router/routes'
|
||||
import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail'
|
||||
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type {
|
||||
ConversationSummary,
|
||||
@@ -214,6 +215,17 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
|
||||
|
||||
const threadRequests = () => requests.filter((request) => request.url === '/conversations')
|
||||
const messageListRequests = () => requests.filter((request) => request.url === '/messages' && request.method === 'GET')
|
||||
/** 목록 머리의 개수 칸 — 보이는 글 · 화면 낭독 글 · title (분할 보기는 줄인 글을 보인다) */
|
||||
const headCount = (wrapper: VueWrapper) => {
|
||||
const count = wrapper.get('.list-header__count')
|
||||
const shown = count.find('[aria-hidden="true"]')
|
||||
const spoken = count.find('.visually-hidden')
|
||||
return {
|
||||
shown: shown.exists() ? shown.text() : count.text(),
|
||||
spoken: spoken.exists() ? spoken.text() : count.text(),
|
||||
title: count.attributes('title'),
|
||||
}
|
||||
}
|
||||
const rowTexts = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('.thread-row').map((row) => ({
|
||||
who: row.get('.thread-row__who').text(),
|
||||
@@ -235,7 +247,12 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
|
||||
{ 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통')
|
||||
// 분할 보기(좁은 머리)는 안 읽은 메일이 있으면 그 수만 보이고 전체 글은 title · 화면 낭독에 (2026-11-06 실사용 시험)
|
||||
expect(headCount(wrapper)).toEqual({
|
||||
shown: '안 읽은 메일 2통',
|
||||
spoken: '대화 6개, 안 읽은 메일 2통',
|
||||
title: '대화 6개, 안 읽은 메일 2통',
|
||||
})
|
||||
const toggle = wrapper.get('[data-group-toggle]')
|
||||
expect(toggle.attributes('aria-pressed')).toBe('true')
|
||||
// 분할 보기의 목록 칸은 아이콘만 — 이름은 title · 화면 낭독
|
||||
@@ -274,7 +291,11 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
|
||||
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(headCount(wrapper)).toEqual({
|
||||
shown: '안 읽은 메일 2통',
|
||||
spoken: '메일 9통, 안 읽은 메일 2통',
|
||||
title: '메일 9통, 안 읽은 메일 2통',
|
||||
})
|
||||
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
|
||||
|
||||
await wrapper.get('[data-group-toggle]').trigger('click')
|
||||
@@ -324,12 +345,25 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
|
||||
expect(requests.filter((request) => request.method === 'PATCH')).toEqual([])
|
||||
})
|
||||
|
||||
it('고르면 도구 줄 "대화 1개 선택됨" — 하는 일은 그 줄의 메일 모두', async () => {
|
||||
it('고르면 도구 줄 "대화 1개 선택됨" — 분할 보기는 "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개 선택됨')
|
||||
// 단추 일곱과 한 줄에 들게 보이는 글만 줄인다 (2026-11-06 실사용 시험 — "대화 1개 선택됨"이면 두 줄로 넘쳤다)
|
||||
const count = wrapper.get('.bulk-bar__count')
|
||||
expect(count.get('[aria-hidden="true"]').text()).toBe('1개 선택됨')
|
||||
expect(count.get('.visually-hidden').text()).toBe('대화 1개 선택됨')
|
||||
expect(count.attributes('id')).toBe(wrapper.get('.mail-list-frame').attributes('aria-labelledby'))
|
||||
// 단추 차례는 그대로
|
||||
expect(wrapper.findAll('[role="toolbar"] [data-tool]').map((tool) => tool.attributes('data-tool'))).toEqual([
|
||||
'markRead',
|
||||
'markUnread',
|
||||
'flag',
|
||||
'move',
|
||||
'delete',
|
||||
'clear',
|
||||
])
|
||||
|
||||
await wrapper.get('[data-tool="markRead"]').trigger('click')
|
||||
await settle()
|
||||
@@ -339,6 +373,29 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
|
||||
expect(wrapper.findAll('.thread-row')[0]?.classes()).not.toContain('is-unread')
|
||||
})
|
||||
|
||||
it('줄에 한 삭제의 알림은 "받은편지함에 있는 메일 N통을 휴지통으로 …" — 줄의 통 수는 대화 전체라서, 되돌리면 "메일 N통을 받은편지함으로 되돌렸습니다."', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper } = await mountMail()
|
||||
|
||||
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await settle()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
|
||||
{ ids: ['m6', 'm5'], mailboxId: 'trash' },
|
||||
])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다.')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await settle()
|
||||
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').at(-1)?.data).toEqual({
|
||||
ids: ['m6', 'm5'],
|
||||
mailboxId: 'inbox',
|
||||
})
|
||||
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('휴지통에서 고른 대화를 영구 삭제 — 확인 창의 수는 메일 수이고 그 줄의 메일을 POST /message-deletions', async () => {
|
||||
rememberGrouped()
|
||||
rows = [conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }) }), conversation('t2', ['m1'])]
|
||||
@@ -365,6 +422,12 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
|
||||
|
||||
const heads = wrapper.findAll('table.mail-table thead th').map((th) => th.text())
|
||||
expect(heads).not.toContain('크기')
|
||||
// 사람 칸 머리는 어느 메일함이든 "참여자" — 줄이 대화의 참여자다 (2026-11-06 실사용 시험)
|
||||
expect(heads).toContain('참여자')
|
||||
expect(heads).not.toContain('보낸 사람')
|
||||
// 목록 보기(넓은 머리)는 개수 글을 다 보인다
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('대화 6개, 안 읽은 메일 2통')
|
||||
expect(wrapper.find('.list-header__count .visually-hidden').exists()).toBe(false)
|
||||
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([
|
||||
@@ -400,6 +463,31 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
|
||||
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c9', 'c1', 'c2', 'c3'])
|
||||
})
|
||||
|
||||
it('다른 탭에서 끈 묶기 — 60초 뒤 탭으로 돌아오면 설정을 조용히 다시 받아 메일 목록으로 다시 연다 (2026-11-06 실사용 시험)', async () => {
|
||||
rememberGrouped()
|
||||
const { wrapper } = await mountMail()
|
||||
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
|
||||
const settingsRequests = () => requests.filter((request) => request.url === '/mail-settings' && request.method === 'GET')
|
||||
expect(settingsRequests()).toHaveLength(1)
|
||||
|
||||
// 다른 탭이 껐다
|
||||
settings = { ...settings, groupByConversation: false }
|
||||
clock.advance(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await settle()
|
||||
expect(settingsRequests()).toHaveLength(1)
|
||||
|
||||
clock.advance(1)
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await settle()
|
||||
expect(settingsRequests()).toHaveLength(2)
|
||||
expect(settingsRequests()[1]?.config.silent).toBe(true)
|
||||
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
describe('세션이 끝나면 목록을 부르지 않는다 (리뷰 M1)', () => {
|
||||
it.each([
|
||||
['로그아웃', () => useAuthStore().clearAuth()],
|
||||
|
||||
@@ -118,18 +118,18 @@ describe('MailSendReadPage', () => {
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
|
||||
})
|
||||
|
||||
it('대화로 묶어 보기(14단계) — 끔 · 켬 전환과 설명, 고르면 그 칸만 PATCH 하고 "저장했습니다"', async () => {
|
||||
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(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')
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 머리 "보내기 · 읽기" + 설명 + 오른쪽에 "저장했습니다"(role=status — 저장 단추 없음) →
|
||||
// 「보내기 취소」 전환(36 — .segmented 밀도 예외: 끔 · 5초 · 10초 · 20초 · 30초) + 설명 → 가는 선 →
|
||||
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자 → 가는 선 →
|
||||
// 「대화로 묶어 보기」 전환(36 — 끔 · 켬, 14단계 — 목록 머리의 [대화로 묶기]와 같은 설정) + 설명.
|
||||
// 「대화로 묶기」 전환(36 — 끔 · 켬, 14단계 — 목록 머리의 [대화로 묶기]와 같은 설정 · 같은 이름) + 설명.
|
||||
// - 들어올 때마다 새로 받는다(다른 PC 에서 바꿨을 수 있다 — 서버에 저장되어 어느 PC 에서나 같다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
|
||||
// - 고르는 대로 그 칸만 PATCH 한다(mail-settings.store — 화면을 먼저 바꾸고 실패하면 고르기 전 값으로 되돌린다, 알림은 인터셉터).
|
||||
// 저장이 끝나면 "저장했습니다", 다시 고르면 내린다
|
||||
|
||||
@@ -40,6 +40,11 @@ export const MAIL_CONVERSATION_TEXT = {
|
||||
current: '지금 보고 있는 메일',
|
||||
reply: '답장',
|
||||
openOnly: '이 메일만 열기',
|
||||
/**
|
||||
* 펼친 줄의 본문 아래 · [답장] 위 — 첨부 목록은 읽기 화면에만 있다. 수는 읽기 화면의 "첨부 파일 N개"와 같다(본문 그림 빼고 · 대용량 첨부 더해)
|
||||
* (2026-11-06 실사용 시험 — 펼친 줄에 첨부가 있다는 표시가 없었다)
|
||||
*/
|
||||
attachmentsNote: (count: string) => `첨부 파일 ${count}개는 [이 메일만 열기]를 누르면 볼 수 있습니다.`,
|
||||
/** 펼친 줄의 머리 — 읽기 화면 머리와 같은 이름 ("받는 사람 김아라 <…>" · "제목 …") */
|
||||
recipients: '받는 사람',
|
||||
subject: '제목',
|
||||
@@ -68,8 +73,18 @@ export const MAIL_CONVERSATION_LIST_TEXT = {
|
||||
rowCount: (count: string) => `${count}통`,
|
||||
/** 여러 통 처리 도구 줄의 머리 — 고른 것은 대화다 */
|
||||
selected: (count: string) => `대화 ${count}개 선택됨`,
|
||||
/**
|
||||
* 분할 보기(목록 칸이 좁다)의 도구 줄 머리 — 보이는 글만 줄인다(단추 일곱이 한 줄에 들게). 화면 낭독은 selected 전체를 읽는다
|
||||
* (2026-11-06 실사용 시험 — "대화 1개 선택됨"이면 단추가 두 줄로 넘쳤다)
|
||||
*/
|
||||
selectedShort: (count: string) => `${count}개 선택됨`,
|
||||
/** 표 머리의 고르기 상자 */
|
||||
selectAll: '불러온 대화 모두 고르기',
|
||||
/**
|
||||
* 목록 보기 표의 사람 칸 머리 — 어느 메일함이든 같다(줄이 대화의 참여자라서. 묶지 않은 표는 "보낸 사람" · 보낸편지함 "받는 사람" 그대로)
|
||||
* (2026-11-06 실사용 시험 — 보낸편지함에서 머리는 "보낸 사람"인데 줄은 "받는 사람: …"이었다)
|
||||
*/
|
||||
participantsColumn: '참여자',
|
||||
/** 목록 보기 표의 caption — 메일 목록은 "받은편지함 메일 목록" */
|
||||
tableCaption: (mailbox: string) => `${mailbox} 대화 목록`,
|
||||
/** 줄의 메일이 모두 보내기를 기다리는 메일일 때 별(중요 표시)의 까닭 — 고르기 칸은 메일 줄과 같은 글(SEND_LATER_TEXT.heldNotSelectable) */
|
||||
|
||||
@@ -81,7 +81,8 @@ export const SEARCH_CONDITIONS_TEXT = {
|
||||
fromPlaceholder: '이름이나 메일 주소',
|
||||
toPlaceholder: '이름이나 메일 주소',
|
||||
subjectPlaceholder: '제목에 든 말',
|
||||
bodyPlaceholder: '본문에 든 말',
|
||||
/** 칸 이름("내용")과 같은 말로 — 조건 줄도 "내용에 ‘…’이 든 메일"이다 */
|
||||
bodyPlaceholder: '내용에 든 말',
|
||||
since: '시작 날짜',
|
||||
until: '끝 날짜',
|
||||
attachmentOnly: '첨부 파일이 있는 메일만',
|
||||
@@ -98,20 +99,29 @@ export const SEARCH_CONDITION_LINE_TEXT = {
|
||||
separator: ', ',
|
||||
from: (who: string) => `${who} 님이 보낸 메일`,
|
||||
to: (who: string) => `${who} 님이 받은 메일`,
|
||||
/** 보낸 사람 · 받는 사람 칸의 값이 내 이름이나 내 주소일 때 (2026-11-06 실사용 시험 — "김아라 님이 보낸 메일"보다 바로 읽힌다) */
|
||||
fromMe: '내가 보낸 메일',
|
||||
toMe: '내가 받은 메일',
|
||||
/** 조사(이 · 가)는 부르는 쪽이 붙인다 */
|
||||
subject: (text: string, particle: string) => `제목에 ‘${text}’${particle} 든 메일`,
|
||||
body: (text: string, particle: string) => `내용에 ‘${text}’${particle} 든 메일`,
|
||||
since: (day: string) => `${day}부터`,
|
||||
until: (day: string) => `${day}까지`,
|
||||
range: (since: string, until: string) => `${since}부터 ${until}까지`,
|
||||
/** 시작 날짜와 끝 날짜가 같은 날 — "2026. 11. 6.부터 11. 6.까지" 대신 */
|
||||
oneDay: (day: string) => `${day} 하루`,
|
||||
attachment: '첨부 있음',
|
||||
clear: '조건 지우기',
|
||||
} as const
|
||||
|
||||
/** 결과 머리 아래 안내 줄 · 결과가 모자랄 때 (디자인 원본 Search14 "검색 준비 중") */
|
||||
export const SEARCH_NOTICE_TEXT = {
|
||||
/** 지난 메일의 색인을 만드는 동안 (GET /search-index 의 building) */
|
||||
indexing: '지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다.',
|
||||
/**
|
||||
* 지난 메일의 색인을 만드는 동안 (GET /search-index 의 building). 색인은 밤에만 채우므로 그렇게 말한다 —
|
||||
* 낮 내내 같은 글이면 멈춘 줄 안다 (2026-11-06 실사용 시험)
|
||||
*/
|
||||
indexing:
|
||||
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
|
||||
/** 한 글자 낱말뿐이라 본문은 찾지 않았을 때 (서버와 같은 낱말 나누기 — search-match.ts) */
|
||||
singleChar:
|
||||
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.',
|
||||
|
||||
@@ -60,8 +60,8 @@ export const SEND_LATER_TEXT = {
|
||||
undone: '보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.',
|
||||
/** 쓰기 화면으로 가지 못했을 때(지금 화면의 나가기 확인에서 머묾 등 — 보드에 없다) — 단추는 [고치기](edit) */
|
||||
undoneStayed: '보내기를 취소하고 임시보관함으로 옮겼습니다.',
|
||||
/** time 은 formatScheduleTime — "10월 30일 (금) 오전 9:00 에 보냅니다." (보드 그대로 띄어 쓴다) */
|
||||
scheduled: (time: string) => `${time} 에 보냅니다.`,
|
||||
/** time 은 formatScheduleTime — "10월 30일 (금) 오전 9:00에 보냅니다." (조사는 붙여 쓴다 — 2026-11-06 실사용 시험) */
|
||||
scheduled: (time: string) => `${time}에 보냅니다.`,
|
||||
viewScheduled: '예약됨 보기',
|
||||
|
||||
// ── 예약됨 (메일함 패널 · 목록 · 읽기 화면) ──
|
||||
@@ -74,8 +74,8 @@ export const SEND_LATER_TEXT = {
|
||||
emptyHint: '메일을 쓸 때 [보내기 ▾] → [예약 발송…]으로 보낼 시각을 정할 수 있습니다.',
|
||||
/** 표 · 목록의 보낼 시각 칸 이름 */
|
||||
sendAtColumn: '보낼 시각',
|
||||
/** 읽기 화면 머리 아래 청록 줄 — "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." */
|
||||
notice: (time: string) => `${time} 에 보낼 메일입니다.`,
|
||||
/** 읽기 화면 머리 아래 청록 줄 — "10월 30일 (금) 오전 9:00에 보낼 메일입니다." */
|
||||
notice: (time: string) => `${time}에 보낼 메일입니다.`,
|
||||
edit: '고치기',
|
||||
cancelSchedule: '예약 취소',
|
||||
canceled: '예약을 취소하고 임시보관함으로 옮겼습니다.',
|
||||
|
||||
@@ -11,6 +11,12 @@ export const EXTERNAL_IMAGES_OPTIONS: readonly ExternalImagesSetting[] = ['ask',
|
||||
/** 대화로 묶어 보기 (14단계) — 고르는 차례 그대로 (끔 · 켬 — 보내기 취소처럼 끔이 앞) */
|
||||
export const GROUP_BY_CONVERSATION_OPTIONS: readonly boolean[] = [false, true]
|
||||
|
||||
/**
|
||||
* 메일 화면이 탭으로 돌아올 때 설정을 다시 받는 간격 — 마지막으로 받은 지 이만큼 지났을 때만 (useMailSettingsRefresh).
|
||||
* 다른 탭 · PC 에서 바꾼 묶기 · 보내기 취소 초 · 외부 이미지가 열어 둔 탭에 남지 않게 하되, 탭을 오갈 때마다 묻지는 않는다
|
||||
*/
|
||||
export const MAIL_SETTINGS_REFRESH_AFTER_MS = 60_000
|
||||
|
||||
export const MAIL_SETTINGS_TEXT = {
|
||||
title: '보내기 · 읽기',
|
||||
/** 보드에 없다 — 저장 단추가 없으니 고르는 대로 저장된다는 것을 머리에서 알린다 */
|
||||
@@ -35,10 +41,14 @@ export const MAIL_SETTINGS_TEXT = {
|
||||
imagesNote:
|
||||
'외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
|
||||
|
||||
/** 대화로 묶어 보기 (14단계 — 디자인 README "목록 — 대화로 묶어 보기 (ListThreads14)" 의 켬/끔 한 줄. 보드가 없어 글은 정했다) */
|
||||
groupTitle: '대화로 묶어 보기',
|
||||
/**
|
||||
* 대화로 묶기 (14단계 — 디자인 README "목록 — 대화로 묶어 보기 (ListThreads14)" 의 켬/끔 한 줄. 보드가 없어 글은 정했다).
|
||||
* 이름은 목록 위 단추와 같다. 분할 보기의 그 단추는 아이콘(말풍선)만이라 설명에서 모양을 말한다 — 이름까지 넣으면 목록 머리에 개수 글이 들 자리가 없다
|
||||
* (2026-11-06 실사용 시험: 이름까지 99px · 아이콘만 36px)
|
||||
*/
|
||||
groupTitle: '대화로 묶기',
|
||||
groupOff: '끔',
|
||||
groupOn: '켬',
|
||||
groupHint:
|
||||
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 목록 위의 [대화로 묶기]로도 켜고 끌 수 있습니다.',
|
||||
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
|
||||
} as const
|
||||
|
||||
@@ -291,12 +291,19 @@ export const MAIL_SEARCH_TEXT = {
|
||||
scopeAll: '전체 메일함',
|
||||
scopeAllHint: '휴지통과 스팸메일함은 빼고 찾습니다',
|
||||
emptyTitle: (query: string) => (query ? `‘${query}’에 맞는 메일이 없습니다` : '조건에 맞는 메일이 없습니다'),
|
||||
/** 결과 없음의 도움말 (14단계 디자인 원본 Search14) — 조건만으로 찾았으면 emptyConditionsHint */
|
||||
/**
|
||||
* 결과 없음의 도움말 (14단계 디자인 원본 Search14) — 검색어와 조건으로 찾았으면 emptyHint, 조건만으로 찾았으면 emptyConditionsHint,
|
||||
* 검색어만으로 찾았으면(걸린 조건이 없다) emptyWordsHint — 없는 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
|
||||
*/
|
||||
emptyHint: '다른 말로 찾거나 조건을 줄여 보세요.',
|
||||
emptyConditionsHint: '조건을 바꾸거나 줄여 보세요.',
|
||||
/** 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 14단계 GET /search-index 의 building)에만 결과 없음에 더하는 한계 안내 */
|
||||
emptyWordsHint: '다른 말로 찾아 보세요.',
|
||||
/**
|
||||
* 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 14단계 GET /search-index 의 building)에만 결과 없음에 더하는 한계 안내.
|
||||
* 바로 위의 "검색 준비 중" 줄과 같은 말(낱말 단위)을 되풀이하지 않고 찾지 못하는 예만 든다 (2026-11-06 실사용 시험)
|
||||
*/
|
||||
wordLimitHint:
|
||||
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
'준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
searchAll: '전체 메일함에서 찾기',
|
||||
/** 결과 목록의 보이지 않는 이름 (불러오는 동안) */
|
||||
loading: '찾는 중',
|
||||
@@ -331,8 +338,15 @@ export const MAIL_BULK_TEXT = {
|
||||
selectOne: (subject: string) => `고르기: ${subject}`,
|
||||
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("보낸편지함으로") */
|
||||
moved: (count: string, place: string) => `메일 ${count}통을 ${place} 옮겼습니다.`,
|
||||
/**
|
||||
* 대화로 묶은 목록에서 줄에 한 옮기기 · 삭제 · 스팸 신고 (14단계) — 줄의 "4통"은 대화 전체라 옮긴 것은 지금 메일함의 메일임을 밝힌다
|
||||
* ("받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 2026-11-06 실사용 시험)
|
||||
*/
|
||||
movedFrom: (from: string, count: string, place: string) => `${from}에 있는 메일 ${count}통을 ${place} 옮겼습니다.`,
|
||||
purged: (count: string) => `메일 ${count}통을 영구 삭제했습니다.`,
|
||||
restored: (count: string) => `메일 ${count}통을 되돌렸습니다.`,
|
||||
/** 한 메일함으로 되돌렸을 때 — place 에는 메일함 이름과 조사를 붙여 넘긴다 ("받은편지함으로"). 여러 곳이면 restored */
|
||||
restoredTo: (count: string, place: string) => `메일 ${count}통을 ${place} 되돌렸습니다.`,
|
||||
skipped: (count: string) => `${count}통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.`,
|
||||
purgeTitle: (count: string) => `메일 ${count}통을 영구 삭제할까요?`,
|
||||
purgeMessage: '휴지통에서 지운 메일은 되돌릴 수 없습니다.',
|
||||
@@ -348,6 +362,9 @@ export const MAIL_SPAM_TEXT = {
|
||||
notSpam: '스팸 아님',
|
||||
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("받은편지함으로") */
|
||||
notSpamMoved: (count: string, place: string) => `메일 ${count}통을 스팸이 아닌 메일로 표시해 ${place} 옮겼습니다.`,
|
||||
/** 대화로 묶은 목록에서 줄에 한 스팸 아님 (14단계 — MAIL_BULK_TEXT.movedFrom 과 같은 꼴) */
|
||||
notSpamMovedFrom: (from: string, count: string, place: string) =>
|
||||
`${from}에 있는 메일 ${count}통을 스팸이 아닌 메일로 표시해 ${place} 옮겼습니다.`,
|
||||
} as const
|
||||
|
||||
/** 읽기 화면 도구 줄의 ⋯ (더 보기 40×44) 메뉴 */
|
||||
|
||||
@@ -72,4 +72,26 @@ describe('mail-bulk (여러 통 처리 보조)', () => {
|
||||
'메일 2통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('대화로 묶은 목록에서 줄에 한 일 — "{메일함}에 있는 메일 N통을 …" (줄의 통 수는 대화 전체라서 — 14단계), 휴지통 · 스팸도 같은 꼴', () => {
|
||||
expect(movedMessageText(2, '휴지통', 0, { from: '받은편지함' })).toBe('받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다.')
|
||||
expect(movedMessageText(3, '스팸메일함', 0, { from: '프로젝트' })).toBe('프로젝트에 있는 메일 3통을 스팸메일함으로 옮겼습니다.')
|
||||
expect(movedMessageText(2, '보관함', 1, { from: '받은편지함' })).toBe(
|
||||
'받은편지함에 있는 메일 2통을 보관함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
)
|
||||
expect(notSpamMovedText(1, '받은편지함', 0, { from: '스팸메일함' })).toBe(
|
||||
'스팸메일함에 있는 메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다.',
|
||||
)
|
||||
// 묶지 않은 목록(from 없음)은 지금 글 그대로
|
||||
expect(movedMessageText(2, '휴지통', 0, { from: null })).toBe('메일 2통을 휴지통으로 옮겼습니다.')
|
||||
})
|
||||
|
||||
it('되돌린 알림 — 한 메일함이면 "메일 N통을 {메일함}으로 되돌렸습니다.", 여러 곳 · 모르면 지금 글 그대로', () => {
|
||||
expect(restoredMessageText(2, 0, '받은편지함')).toBe('메일 2통을 받은편지함으로 되돌렸습니다.')
|
||||
expect(restoredMessageText(1, 0, '프로젝트')).toBe('메일 1통을 프로젝트로 되돌렸습니다.')
|
||||
expect(restoredMessageText(2, 1, '받은편지함')).toBe(
|
||||
'메일 2통을 받은편지함으로 되돌렸습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
)
|
||||
expect(restoredMessageText(3, 0, null)).toBe('메일 3통을 되돌렸습니다.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,22 +75,47 @@ function withSkipped(text: string, done: number, skipped: number): string {
|
||||
return done > 0 ? `${text} ${suffix}` : suffix
|
||||
}
|
||||
|
||||
/** "메일 3통을 보낸편지함으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로' */
|
||||
export function movedMessageText(done: number, mailboxName: string, skipped = 0): string {
|
||||
const place = `${mailboxName}${directionParticle(mailboxName)}`
|
||||
return withSkipped(MAIL_BULK_TEXT.moved(formatCount(done), place), done, skipped)
|
||||
/** "받은편지함으로" — 메일함 이름의 받침에 맞춰 '으로/로' */
|
||||
function placeOf(mailboxName: string): string {
|
||||
return `${mailboxName}${directionParticle(mailboxName)}`
|
||||
}
|
||||
|
||||
/** 스팸 아님 — "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." (스팸 신고는 옮기기와 같은 문구 — movedMessageText) */
|
||||
export function notSpamMovedText(done: number, mailboxName: string, skipped = 0): string {
|
||||
const place = `${mailboxName}${directionParticle(mailboxName)}`
|
||||
return withSkipped(MAIL_SPAM_TEXT.notSpamMoved(formatCount(done), place), done, skipped)
|
||||
/** 옮기기 알림의 꾸밈 — from 이 있으면 "{from}에 있는 메일 …" (대화로 묶은 목록에서 줄에 한 일 — 14단계) */
|
||||
export interface MovedTextOptions {
|
||||
from?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* "메일 3통을 보낸편지함으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로'.
|
||||
* 대화로 묶은 목록에서 줄에 한 일이면 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." (줄의 통 수는 대화 전체라서)
|
||||
*/
|
||||
export function movedMessageText(done: number, mailboxName: string, skipped = 0, options: MovedTextOptions = {}): string {
|
||||
const count = formatCount(done)
|
||||
const text = options.from
|
||||
? MAIL_BULK_TEXT.movedFrom(options.from, count, placeOf(mailboxName))
|
||||
: MAIL_BULK_TEXT.moved(count, placeOf(mailboxName))
|
||||
return withSkipped(text, done, skipped)
|
||||
}
|
||||
|
||||
/** 스팸 아님 — "메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다." (스팸 신고는 옮기기와 같은 문구 — movedMessageText) */
|
||||
export function notSpamMovedText(done: number, mailboxName: string, skipped = 0, options: MovedTextOptions = {}): string {
|
||||
const count = formatCount(done)
|
||||
const text = options.from
|
||||
? MAIL_SPAM_TEXT.notSpamMovedFrom(options.from, count, placeOf(mailboxName))
|
||||
: MAIL_SPAM_TEXT.notSpamMoved(count, placeOf(mailboxName))
|
||||
return withSkipped(text, done, skipped)
|
||||
}
|
||||
|
||||
export function purgedMessageText(done: number, skipped = 0): string {
|
||||
return withSkipped(MAIL_BULK_TEXT.purged(formatCount(done)), done, skipped)
|
||||
}
|
||||
|
||||
export function restoredMessageText(done: number, skipped = 0): string {
|
||||
return withSkipped(MAIL_BULK_TEXT.restored(formatCount(done)), done, skipped)
|
||||
/**
|
||||
* 되돌리기 — 한 메일함으로 되돌렸으면 "메일 2통을 받은편지함으로 되돌렸습니다.", 여러 곳(또는 메일함 이름을 모름)이면 "메일 N통을 되돌렸습니다."
|
||||
* (2026-11-06 실사용 시험 — 되돌리기 알림 두 장이 같은 글로 쌓였다)
|
||||
*/
|
||||
export function restoredMessageText(done: number, skipped = 0, mailboxName: string | null = null): string {
|
||||
const count = formatCount(done)
|
||||
const text = mailboxName ? MAIL_BULK_TEXT.restoredTo(count, placeOf(mailboxName)) : MAIL_BULK_TEXT.restored(count)
|
||||
return withSkipped(text, done, skipped)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
conversationWho,
|
||||
isConversationHeld,
|
||||
listHeadCountText,
|
||||
listHeadShortCountText,
|
||||
mergeRefreshedConversations,
|
||||
withConversationFlags,
|
||||
withConversationFlagsOf,
|
||||
@@ -207,6 +208,14 @@ describe('줄의 글', () => {
|
||||
expect(listHeadCountText({ grouped: true, total: 1500, unread: 0 })).toBe('대화 1,500개')
|
||||
expect(listHeadCountText({ grouped: true, total: null, unread: 0 })).toBe('')
|
||||
})
|
||||
|
||||
it('좁은 목록 머리(분할 보기) — 안 읽은 메일이 있으면 "안 읽은 메일 2통"만, 없으면 "메일 9통" · "대화 6개" (2026-11-06 실사용 시험)', () => {
|
||||
expect(listHeadShortCountText({ grouped: true, total: 6, unread: 2 })).toBe('안 읽은 메일 2통')
|
||||
expect(listHeadShortCountText({ grouped: false, total: 9, unread: 1_234 })).toBe('안 읽은 메일 1,234통')
|
||||
expect(listHeadShortCountText({ grouped: false, total: 9, unread: 0 })).toBe('메일 9통')
|
||||
expect(listHeadShortCountText({ grouped: true, total: 15, unread: 0 })).toBe('대화 15개')
|
||||
expect(listHeadShortCountText({ grouped: true, total: null, unread: 0 })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('이 탭이 바꾼 표시', () => {
|
||||
|
||||
@@ -163,7 +163,8 @@ export interface ConversationWhoContext {
|
||||
|
||||
/**
|
||||
* 줄의 이름 자리 — 참여자를 쉼표로 ("이도윤, 나"). 보낸편지함 · 임시보관함에서 참여자가 나뿐(또는 없음)이면 마지막 메일의 받는 사람을
|
||||
* "받는 사람: …" 으로(표 머리는 "보낸 사람"이라 앞말을 붙인다), 그 밖에서 참여자가 없으면 마지막 메일의 보낸 사람 규칙(mail-list-row.ts)
|
||||
* "받는 사람: …" 으로(표 머리 "참여자" 아래에서 참여자가 아니라 받는 사람인 것을 가르려고 앞말을 붙인다), 그 밖에서 참여자가 없으면
|
||||
* 마지막 메일의 보낸 사람 규칙(mail-list-row.ts)
|
||||
*/
|
||||
export function conversationWho(row: ConversationSummary, context: ConversationWhoContext): RowWho {
|
||||
const names = conversationParticipantNames(row, context.myEmail)
|
||||
@@ -196,6 +197,15 @@ export function listHeadCountText(input: { grouped: boolean; total: number | nul
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 좁은 목록 머리(분할 보기)의 글 — 안 읽은 메일이 있으면 "안 읽은 메일 2통"만, 없으면 "메일 9통"(묶었으면 "대화 6개", 모르면 빈 글).
|
||||
* 전체 글(listHeadCountText)은 title · 화면 낭독에 둔다 (2026-11-06 실사용 시험 — 1440 분할 보기에서 안 읽은 수가 늘 잘렸다)
|
||||
*/
|
||||
export function listHeadShortCountText(input: { grouped: boolean; total: number | null; unread: number }): string {
|
||||
if (input.unread > 0) return MAIL_CONVERSATION_LIST_TEXT.unreadCount(formatCount(input.unread))
|
||||
return listHeadCountText({ ...input, unread: 0 })
|
||||
}
|
||||
|
||||
// ── 이 탭이 바꾼 것 ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
hasConversation,
|
||||
nextConversationCursor,
|
||||
participantName,
|
||||
previewWithoutOwnQuote,
|
||||
type ConversationLinkContext,
|
||||
} from './mail-conversation'
|
||||
|
||||
@@ -253,7 +254,7 @@ describe('conversationRowsOf — 칸의 줄', () => {
|
||||
expect(rows[0]?.location).toEqual({ name: 'mail-message', params: { mailboxId: 'sent', messageId: 'm1' } })
|
||||
})
|
||||
|
||||
it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중" — <time> 은 보낼 시각', () => {
|
||||
it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중" — <time> 은 보낼 시각, 받은 줄 뒤에 둔다', () => {
|
||||
const sendAt = '2026-10-30T00:00:00Z'
|
||||
const rows = conversationRowsOf(
|
||||
[
|
||||
@@ -264,11 +265,79 @@ describe('conversationRowsOf — 칸의 줄', () => {
|
||||
context,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => [row.time, row.pending, row.timeValue])).toEqual([
|
||||
['예약 · 10월 30일 오전 9:00', true, sendAt],
|
||||
['보내는 중', true, sendAt],
|
||||
['오늘 오전 9:30', false, '2026-10-29T00:30:00Z'],
|
||||
expect(rows.map((row) => [row.message.id, row.time, row.pending, row.timeValue])).toEqual([
|
||||
['m3', '오늘 오전 9:30', false, '2026-10-29T00:30:00Z'],
|
||||
['s1', '예약 · 10월 30일 오전 9:00', true, sendAt],
|
||||
['s2', '보내는 중', true, sendAt],
|
||||
])
|
||||
expect(rows[1]?.replyLocation).toBeNull()
|
||||
})
|
||||
|
||||
it('보내기를 기다리는 메일(pendingSend)은 받은 줄의 맨 끝에 보낼 시각 차례로 — 서버 차례(저장한 시각)를 거스르지 않게', () => {
|
||||
const rows = conversationRowsOf(
|
||||
[
|
||||
summary('m1', { receivedAt: '2026-10-29T00:10:00Z' }),
|
||||
// 10시 38분에 저장한 예약 메일(11시 38분에 보낸다) — 서버는 저장한 시각 차례로 준다
|
||||
summary('late', {
|
||||
mailboxIds: ['sent'],
|
||||
receivedAt: '2026-10-29T01:38:00Z',
|
||||
pendingSend: { kind: 'scheduled', sendAt: '2026-10-29T02:38:00Z' },
|
||||
}),
|
||||
summary('early', {
|
||||
mailboxIds: ['sent'],
|
||||
receivedAt: '2026-10-29T01:39:00Z',
|
||||
pendingSend: { kind: 'scheduled', sendAt: '2026-10-29T02:00:00Z' },
|
||||
}),
|
||||
summary('m2', { receivedAt: '2026-10-29T02:00:00Z' }),
|
||||
summary('m3', { receivedAt: '2026-10-29T02:10:00Z' }),
|
||||
],
|
||||
context,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => row.message.id)).toEqual(['m1', 'm2', 'm3', 'early', 'late'])
|
||||
})
|
||||
|
||||
it('줄의 미리 보기 — 우리 웹 · 앱의 원문 인용("> 원본 메일")부터 뒤는 잘라 내고, 남는 글이 없으면 제목', () => {
|
||||
const rows = conversationRowsOf(
|
||||
[
|
||||
summary('m1', { preview: '도윤님, 금요일까지 정하겠습니다. > 원본 메일 > 보낸 사람: 이도윤 <doyun.lee@arasystem.test>' }),
|
||||
summary('m2', { preview: '> 원본 메일 > 보낸 사람: 이도윤 > 날짜: 2026. 10. 29.' }),
|
||||
summary('m3', { preview: '첨부를 확인해 주세요. > 원본' }),
|
||||
],
|
||||
context,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => row.preview)).toEqual([
|
||||
'도윤님, 금요일까지 정하겠습니다.',
|
||||
'회신: 유지보수 계약 갱신 건',
|
||||
'첨부를 확인해 주세요.',
|
||||
])
|
||||
expect(rows[0]?.replyLocation).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewWithoutOwnQuote — 우리 원문 인용 머리부터 잘라 낸다', () => {
|
||||
it.each([
|
||||
['네, 확인했습니다. > 원본 메일 > 보낸 사람: 김아라', '네, 확인했습니다.'],
|
||||
['> 원본 메일 > 보낸 사람: 김아라', ''],
|
||||
// 답장의 답장 — 처음 나온 머리부터
|
||||
['좋습니다. > 원본 메일 > 보낸 사람: 이도윤 > > 원본 메일 > 보낸 사람: 김아라', '좋습니다.'],
|
||||
// 미리 보기 끝에서 잘린 머리도 머리다 (적어도 "> 원")
|
||||
['내일 뵙겠습니다. > 원본 메', '내일 뵙겠습니다.'],
|
||||
['내일 뵙겠습니다. > 원', '내일 뵙겠습니다.'],
|
||||
])('%s → %s', (preview, expected) => {
|
||||
expect(previewWithoutOwnQuote(preview)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
// 다른 메일 프로그램의 인용 꼴은 손대지 않는다
|
||||
'네. -----Original Message----- From: 이도윤',
|
||||
'네. 2026년 10월 29일 (목) 오전 9:30, 이도윤 <doyun.lee@arasystem.test>님이 작성: > 견적서 보내드립니다.',
|
||||
// 줄의 처음이 아닌 곳의 같은 글은 인용 머리가 아니다
|
||||
'제목이 "A> 원본 메일"인 메일입니다.',
|
||||
// 끝의 ">" 하나는 머리로 보지 않는다
|
||||
'값이 a > b >',
|
||||
'보통 미리 보기',
|
||||
])('%s — 그대로', (preview) => {
|
||||
expect(previewWithoutOwnQuote(preview)).toBe(preview)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
// - 통 수 · 칸 보이기: total → 다 받았으면 받은 수 → 모르면 수 없이. 한 통뿐이면 칸이 없다
|
||||
// - 이름 자리: 내가 보낸 메일(보낸편지함 · 임시보관함에 들었거나 보낸 사람이 나)은 "나" + 회색 메일함 이름
|
||||
// - 누르면 갈 곳: 임시 저장본은 쓰기 화면(목록과 같다), 지금 목록에 있는 메일은 그 목록 안, 아니면 그 메일이 든 메일함
|
||||
// - 줄의 미리 보기: 우리 웹 · 앱이 답장 · 전달에 붙인 원문 인용("> 원본 메일")부터 뒤는 잘라 낸다 — 같은 대화의 줄마다 앞 메일이 되풀이되지 않게.
|
||||
// 남는 글이 없으면 제목. 다른 메일 프로그램의 인용 꼴은 손대지 않는다 (2026-11-06 실사용 시험)
|
||||
// - 줄의 차례: 서버 차례(받은 시각 — 내 메일은 저장한 시각) 그대로, 보내기를 기다리는 메일(pendingSend)만 받은 줄 뒤에 보낼 시각 차례로 —
|
||||
// 예약한 메일이 그 뒤에 온 답장보다 앞에 서지 않게 (2026-11-06 실사용 시험)
|
||||
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { MAIL_NO_SENDER, MAIL_NO_SUBJECT, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import { MAIL_NO_SENDER, MAIL_NO_SUBJECT, MAIL_QUOTE_LINE_PREFIX, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { MAIL_QUOTE_HEADING } from '@/shared/constants/mail-editor'
|
||||
import type { AuthUser } from '@/shared/types/auth'
|
||||
import type { Address, ConversationBasis, ConversationPage, Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import type { PageInfo } from '@/shared/types/pagination'
|
||||
@@ -16,7 +21,7 @@ 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 { mailboxListIdOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { isScheduledMessage } from '@/shared/utils/pending-send'
|
||||
import { isScheduledMessage, messagePendingSend } from '@/shared/utils/pending-send'
|
||||
|
||||
const CONVERSATION_BASES: readonly string[] = ['index', 'engine']
|
||||
|
||||
@@ -233,15 +238,62 @@ export interface ConversationRowContext extends ConversationLinkContext {
|
||||
timeZone?: string
|
||||
}
|
||||
|
||||
/** 받은 대화 → 칸의 줄 (오래된 차례 그대로) */
|
||||
/**
|
||||
* 우리 웹 · 앱이 답장 · 전달의 텍스트 본문에 붙이는 원문 인용의 머리 줄 — "> 원본 메일" (mail-serialize.ts 의 인용 줄 머리 + 인용 머리 글).
|
||||
* 미리 보기는 줄바꿈이 띄어쓰기로 바뀐 글이라 "…정하겠습니다. > 원본 메일 > 보낸 사람: …" 꼴로 온다
|
||||
*/
|
||||
const OWN_QUOTE_MARKER = `${MAIL_QUOTE_LINE_PREFIX}${MAIL_QUOTE_HEADING}`
|
||||
/** 미리 보기 끝에서 잘린 머리("> 원본 메")도 머리로 본다 — 적어도 인용 줄 머리 다음 한 글자("> 원")는 있어야 한다 */
|
||||
const OWN_QUOTE_TAIL_MIN = MAIL_QUOTE_LINE_PREFIX.length + 1
|
||||
const WHITESPACE = /\s/
|
||||
|
||||
/** 그 자리가 글의 처음이거나 바로 앞이 띄어쓰기(줄바꿈)인가 — 인용 머리는 줄의 처음에만 있다 */
|
||||
function startsLine(text: string, index: number): boolean {
|
||||
return index === 0 || WHITESPACE.test(text.charAt(index - 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리 보기에서 우리 웹 · 앱의 원문 인용("> 원본 메일")부터 뒤를 잘라 낸다 (머리말). 미리 보기 끝에서 잘린 머리도 지운다.
|
||||
* 다른 메일 프로그램의 인용(“… 작성:” · "-----Original Message-----" 등)은 그대로 둔다
|
||||
*/
|
||||
export function previewWithoutOwnQuote(preview: string): string {
|
||||
let from = 0
|
||||
for (;;) {
|
||||
const index = preview.indexOf(OWN_QUOTE_MARKER, from)
|
||||
if (index === -1) break
|
||||
if (startsLine(preview, index)) return preview.slice(0, index).trimEnd()
|
||||
from = index + 1
|
||||
}
|
||||
for (let length = OWN_QUOTE_MARKER.length - 1; length >= OWN_QUOTE_TAIL_MIN; length -= 1) {
|
||||
const start = preview.length - length
|
||||
if (start >= 0 && preview.endsWith(OWN_QUOTE_MARKER.slice(0, length)) && startsLine(preview, start)) {
|
||||
return preview.slice(0, start).trimEnd()
|
||||
}
|
||||
}
|
||||
return preview
|
||||
}
|
||||
|
||||
/** 보내기를 기다리는 메일(보내기 취소 · 예약)은 받은 줄 뒤에 보낼 시각 차례로 (머리말) — 나머지는 받은 차례 그대로 */
|
||||
export function orderHeldLast(items: readonly MessageSummary[]): MessageSummary[] {
|
||||
const sendAtOf = (message: MessageSummary) => Date.parse(messagePendingSend(message)?.sendAt ?? '')
|
||||
const held = items.filter((message) => messagePendingSend(message) !== null)
|
||||
if (held.length === 0) return [...items]
|
||||
return [
|
||||
...items.filter((message) => messagePendingSend(message) === null),
|
||||
// 같은 시각이면 받은 차례 그대로 (sort 는 안정 정렬)
|
||||
...held.sort((a, b) => sendAtOf(a) - sendAtOf(b)),
|
||||
]
|
||||
}
|
||||
|
||||
/** 받은 대화 → 칸의 줄 (오래된 차례 — 보내기를 기다리는 메일은 끝에) */
|
||||
export function conversationRowsOf(items: readonly MessageSummary[], context: ConversationRowContext): ConversationRow[] {
|
||||
return items.map((message) => {
|
||||
return orderHeldLast(items).map((message) => {
|
||||
const pendingLabel = pendingSendLabel(message, context.timeZone)
|
||||
return {
|
||||
message,
|
||||
current: message.id === context.currentId,
|
||||
sender: conversationSenderOf(message, context),
|
||||
preview: message.preview.trim() || message.subject || MAIL_NO_SUBJECT,
|
||||
preview: previewWithoutOwnQuote(message.preview).trim() || message.subject || MAIL_NO_SUBJECT,
|
||||
time: pendingLabel ?? formatConversationTime(message.receivedAt, context.now, context.timeZone),
|
||||
timeValue: message.pendingSend?.sendAt ?? message.receivedAt,
|
||||
pending: pendingLabel !== null,
|
||||
|
||||
@@ -243,6 +243,28 @@ describe('조건 줄 글', () => {
|
||||
expect(searchConditionsSummary(conditions({ until: '2026-10-31' }), today)).toBe('조건: 2026. 10. 31.까지')
|
||||
expect(searchConditionsSummary(NO_SEARCH_CONDITIONS, today)).toBe('')
|
||||
})
|
||||
|
||||
it('하루만 고른 기간은 "2026. 11. 6. 하루" — 같은 날을 부터 · 까지로 두 번 쓰지 않는다', () => {
|
||||
expect(searchConditionsSummary(conditions({ since: '2026-11-06', until: '2026-11-06' }), today)).toBe(
|
||||
'조건: 2026. 11. 6. 하루',
|
||||
)
|
||||
expect(searchConditionsSummary(conditions({ since: '2025-12-31', until: '2025-12-31' }), today)).toBe(
|
||||
'조건: 2025. 12. 31. 하루',
|
||||
)
|
||||
})
|
||||
|
||||
it('보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" — 대소문자 · 앞뒤 공백은 가리지 않고, 일부만 같으면 아니다', () => {
|
||||
const me = { name: '김아라', email: 'ara.kim@arasystem.test' }
|
||||
expect(searchConditionsSummary(conditions({ from: '김아라', to: ' ARA.KIM@arasystem.test ' }), today, me)).toBe(
|
||||
'조건: 내가 보낸 메일, 내가 받은 메일',
|
||||
)
|
||||
expect(searchConditionsSummary(conditions({ from: 'ara.kim@arasystem.test' }), today, me)).toBe('조건: 내가 보낸 메일')
|
||||
expect(searchConditionsSummary(conditions({ from: '아라', to: 'ara.kim' }), today, me)).toBe(
|
||||
'조건: 아라 님이 보낸 메일, ara.kim 님이 받은 메일',
|
||||
)
|
||||
// 누구인지 모르면 지금 글 그대로
|
||||
expect(searchConditionsSummary(conditions({ from: '김아라' }), today)).toBe('조건: 김아라 님이 보낸 메일')
|
||||
})
|
||||
})
|
||||
|
||||
describe('한 글자 안내 — 본문을 찾을 말이 한 글자뿐일 때만', () => {
|
||||
|
||||
@@ -279,6 +279,8 @@ function periodText(conditions: SearchConditions, today: Date): string | null {
|
||||
if (period === 'all') return null
|
||||
if (period !== 'custom') return SEARCH_PERIOD_LABELS[period]
|
||||
if (since !== null && until !== null) {
|
||||
// 하루만 고른 기간 — "2026. 11. 6. 하루" (같은 날을 부터 · 까지로 두 번 쓰지 않는다)
|
||||
if (since === until) return SEARCH_CONDITION_LINE_TEXT.oneDay(periodDayText(since, true))
|
||||
const sameYear = since.slice(0, 4) === until.slice(0, 4)
|
||||
return SEARCH_CONDITION_LINE_TEXT.range(periodDayText(since, true), periodDayText(until, !sameYear))
|
||||
}
|
||||
@@ -286,15 +288,43 @@ function periodText(conditions: SearchConditions, today: Date): string | null {
|
||||
return until !== null ? SEARCH_CONDITION_LINE_TEXT.until(periodDayText(until, true)) : null
|
||||
}
|
||||
|
||||
/** 조건 줄의 "나" — 보낸 사람 · 받는 사람 칸의 값이 이 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" */
|
||||
export interface SearchConditionsMe {
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/** 견줄 모양 — 앞뒤 공백을 빼고 NFC · 소문자 (영문 이름 · 주소는 대소문자를 가리지 않는다) */
|
||||
function comparableName(value: string | null | undefined): string {
|
||||
return (value ?? '').trim().normalize('NFC').toLowerCase()
|
||||
}
|
||||
|
||||
/** 칸의 값이 나인가 — 내 이름이나 내 주소와 같을 때만(일부만 같으면 아니다) */
|
||||
function isMeValue(value: string, me: SearchConditionsMe | null | undefined): boolean {
|
||||
if (!me) return false
|
||||
const text = comparableName(value)
|
||||
if (text === '') return false
|
||||
return text === comparableName(me.email) || text === comparableName(me.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 머리 아래 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" (쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다).
|
||||
* 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜. 조건이 없으면 빈 글
|
||||
* 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(하루면 "… 하루"). 보낸 사람 · 받는 사람이 나면 "내가 보낸 메일" · "내가 받은 메일".
|
||||
* 조건이 없으면 빈 글
|
||||
*/
|
||||
export function searchConditionsSummary(conditions: SearchConditions | null | undefined, today: Date): string {
|
||||
export function searchConditionsSummary(
|
||||
conditions: SearchConditions | null | undefined,
|
||||
today: Date,
|
||||
me: SearchConditionsMe | null = null,
|
||||
): string {
|
||||
if (!conditions || !hasSearchConditions(conditions)) return ''
|
||||
const parts: string[] = []
|
||||
if (conditions.from) parts.push(SEARCH_CONDITION_LINE_TEXT.from(conditions.from))
|
||||
if (conditions.to) parts.push(SEARCH_CONDITION_LINE_TEXT.to(conditions.to))
|
||||
if (conditions.from) {
|
||||
parts.push(isMeValue(conditions.from, me) ? SEARCH_CONDITION_LINE_TEXT.fromMe : SEARCH_CONDITION_LINE_TEXT.from(conditions.from))
|
||||
}
|
||||
if (conditions.to) {
|
||||
parts.push(isMeValue(conditions.to, me) ? SEARCH_CONDITION_LINE_TEXT.toMe : SEARCH_CONDITION_LINE_TEXT.to(conditions.to))
|
||||
}
|
||||
if (conditions.subject) {
|
||||
parts.push(SEARCH_CONDITION_LINE_TEXT.subject(conditions.subject, subjectParticle(conditions.subject)))
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ const SETTINGS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', gro
|
||||
|
||||
describe('mail-settings.store — 묶어 보기 짐작 · 받지 못했을 때', () => {
|
||||
let requests: MockRequest[]
|
||||
let load: () => MockResult
|
||||
let patch: (request: MockRequest) => MockResult
|
||||
let load: () => MockResult | Promise<MockResult>
|
||||
let patch: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
@@ -146,4 +146,90 @@ describe('mail-settings.store — 묶어 보기 짐작 · 받지 못했을 때',
|
||||
expect(patches()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('조용히 다시 받기 refresh — 다른 탭 · PC 에서 바꾼 값 (2026-11-06 실사용 시험)', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore().markAuthenticated(ARA)
|
||||
})
|
||||
|
||||
it('받은 값으로 바꾼다 — 받는 동안에도 상태는 ready 그대로(목록 머리의 [대화로 묶기]가 숨지 않게), 알리지 않는다', async () => {
|
||||
const store = useMailSettingsStore()
|
||||
await store.load()
|
||||
const fetched = store.fetchSeq
|
||||
load = () => ok({ undoSendSeconds: 30, externalImages: 'always', groupByConversation: false })
|
||||
|
||||
const refreshing = store.refresh()
|
||||
expect(store.status).toBe('ready')
|
||||
await refreshing
|
||||
|
||||
expect(store.settings).toEqual({ undoSendSeconds: 30, externalImages: 'always', groupByConversation: false })
|
||||
expect(store.groupByConversation).toBe(false)
|
||||
expect(store.knownUndoSendSeconds).toBe(30)
|
||||
expect(store.fetchSeq).toBe(fetched + 1)
|
||||
expect(window.localStorage.getItem(groupByConversationHintKey(ARA.email))).toBe('false')
|
||||
expect(requests.filter((request) => request.method === 'GET').at(-1)?.config.silent).toBe(true)
|
||||
})
|
||||
|
||||
it('받기 전 · 받는 중 · 저장하는 중에는 하지 않는다', async () => {
|
||||
const store = useMailSettingsStore()
|
||||
await store.refresh()
|
||||
expect(requests).toHaveLength(0)
|
||||
|
||||
const loading = store.load()
|
||||
await store.refresh()
|
||||
await loading
|
||||
expect(requests.filter((request) => request.method === 'GET')).toHaveLength(1)
|
||||
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
patch = () => new Promise<MockResult>((resolve) => (release = resolve))
|
||||
const saving = store.update('undoSendSeconds', 5)
|
||||
await store.refresh()
|
||||
expect(requests.filter((request) => request.method === 'GET')).toHaveLength(1)
|
||||
release(ok({ ...SETTINGS, undoSendSeconds: 5 }))
|
||||
await saving
|
||||
})
|
||||
|
||||
it('받는 사이 고른 값이 있으면 받은 값을 버린다 — 늦게 온 옛 값이 방금 고른 것을 덮지 않게', async () => {
|
||||
const store = useMailSettingsStore()
|
||||
await store.load()
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
load = () => new Promise<MockResult>((resolve) => (release = resolve))
|
||||
|
||||
const refreshing = store.refresh()
|
||||
await store.update('groupByConversation', false)
|
||||
release(ok(SETTINGS))
|
||||
await refreshing
|
||||
|
||||
expect(store.groupByConversation).toBe(false)
|
||||
expect(store.settings?.groupByConversation).toBe(false)
|
||||
})
|
||||
|
||||
it('받지 못하면 지금 값을 둔다 — 알리지 않는다', async () => {
|
||||
const store = useMailSettingsStore()
|
||||
await store.load()
|
||||
load = () => fail(503, 'SYS_001')
|
||||
|
||||
await store.refresh()
|
||||
|
||||
expect(store.status).toBe('ready')
|
||||
expect(store.settings).toEqual(SETTINGS)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('세션이 바뀌면 받은 값을 버린다', async () => {
|
||||
const store = useMailSettingsStore()
|
||||
await store.load()
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
load = () => new Promise<MockResult>((resolve) => (release = resolve))
|
||||
|
||||
const refreshing = store.refresh()
|
||||
useAuthStore().clearAuth()
|
||||
await flushPromises()
|
||||
release(ok({ ...SETTINGS, groupByConversation: false }))
|
||||
await refreshing
|
||||
|
||||
expect(store.settings).toBeNull()
|
||||
expect(store.groupByConversation).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,6 +50,11 @@ function writeGroupHint(email: string, grouped: boolean) {
|
||||
// 마지막으로 고른 것의 저장이 실패하면 그 칸을 서버가 마지막으로 확인해 준 값으로 되돌린다(오류 알림은 인터셉터)
|
||||
// - 기다리는 저장이 모두 끝났고 마지막으로 끝난 저장이 성공이면 saved — 화면이 "저장했습니다"를 보인다. 다시 고르면 내린다
|
||||
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) · 다시 받은 뒤(load) 늦게 온 응답은 쓰지 않는다 (authStore.generation · 순번)
|
||||
// - 조용히 다시 받기(refresh — 메일 화면이 탭으로 돌아올 때, 마지막으로 받은 지 60초가 지났으면 useMailSettingsRefresh 가 부른다):
|
||||
// 다른 탭 · PC 에서 바꾼 값(묶기 · 보내기 취소 초 · 외부 이미지)이 열어 둔 탭에 남지 않게. 받는 동안에도 화면은 지금 값 그대로다(loading 으로 바꾸지 않는다).
|
||||
// 저장하는 중이면 하지 않고, 받는 사이 고르거나 다시 받거나 세션이 바뀌었으면 받은 값을 버린다 — 늦게 온 옛 값이 방금 고른 것을 덮지 않게.
|
||||
// 받은 묶기 값이 다르면 메일함 화면이 그 값으로 목록을 다시 연다(MailboxPage 의 watcher). 받지 못하면 알리지 않는다
|
||||
// (2026-11-06 실사용 시험 — 다른 탭에서 끈 묶기가 열어 둔 탭에 남았다). 받을 때마다 fetchSeq 가 오른다(마지막으로 받은 때를 세는 쪽이 본다)
|
||||
export const useMailSettingsStore = defineStore('mail-settings', () => {
|
||||
const settingsApi = useMailSettings()
|
||||
const authStore = useAuthStore()
|
||||
@@ -66,6 +71,12 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
|
||||
const groupHint = ref<boolean>(false)
|
||||
/** 받지 못한 채(error) 고른 묶어 보기의 고르기 전 짐작 — 저장이 실패하면 이 값으로 되돌린다. 고른 저장이 없으면 null */
|
||||
let hintBeforeChoice: boolean | null = null
|
||||
/** 설정을 받은 차례 — load · refresh 가 받을 때마다 오른다 (마지막으로 받은 때를 세는 useMailSettingsRefresh 가 본다) */
|
||||
const fetchSeq = ref<number>(0)
|
||||
/** 고른 차례 — 고를 때마다 오른다. 조용히 다시 받는 사이 고른 것이 있으면 받은 값을 버린다 */
|
||||
let choiceSeq = 0
|
||||
/** 조용히 다시 받는 중 — 한 번에 하나 */
|
||||
let refreshing = false
|
||||
|
||||
/**
|
||||
* 알고 있는 보내기 취소 시간(초) — 받아 둔 설정이 있을 때만, 모르면 null.
|
||||
@@ -119,6 +130,7 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
|
||||
settings.value = { ...result }
|
||||
confirmed = { ...result }
|
||||
status.value = 'ready'
|
||||
fetchSeq.value += 1
|
||||
return result
|
||||
} catch (caught) {
|
||||
if (current !== loadSeq) return settings.value
|
||||
@@ -133,6 +145,35 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
|
||||
if (status.value === 'idle' || status.value === 'error') void load()
|
||||
}
|
||||
|
||||
/**
|
||||
* 조용히 다시 받는다 (머리말) — 받아 둔 값이 있거나 받지 못했을 때만(받기 전 · 받는 중이면 하지 않는다), 저장하는 중이 아닐 때만.
|
||||
* 받는 사이 고르거나(choiceSeq) 다시 받거나(loadSeq) 세션이 바뀌었으면 받은 값을 버리고, 받지 못하면 지금 값을 둔다 (알리지 않는다)
|
||||
*/
|
||||
async function refresh(): Promise<void> {
|
||||
if (status.value !== 'ready' && status.value !== 'error') return
|
||||
if (pending.value > 0 || refreshing) return
|
||||
const session = authStore.generation
|
||||
const sessionLoad = loadSeq
|
||||
const choice = choiceSeq
|
||||
refreshing = true
|
||||
try {
|
||||
const result = await settingsApi.getSettings({ silent: true })
|
||||
const stale =
|
||||
authStore.generation !== session || loadSeq !== sessionLoad || choiceSeq !== choice || pending.value > 0
|
||||
if (stale) return
|
||||
settings.value = { ...result }
|
||||
confirmed = { ...result }
|
||||
hintBeforeChoice = null
|
||||
status.value = 'ready'
|
||||
error.value = null
|
||||
fetchSeq.value += 1
|
||||
} catch {
|
||||
// 알리지 않는다 — 지금 값을 두고 다음에 다시 받는다
|
||||
} finally {
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 고를 수 있는가 — 받는 중 · 받기 전에는 못 한다. 받지 못했으면(error) 받아 둔 값이 있을 때, 없으면 짐작으로 보이는 묶어 보기만 */
|
||||
function canChoose(key: SettingKey): boolean {
|
||||
if (status.value === 'ready') return settings.value !== null
|
||||
@@ -220,6 +261,7 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
|
||||
// 지금 값을 다시 눌렀다 — 보낼 것이 없다
|
||||
if (!running && shownOf(key) === value) return Promise.resolve(true)
|
||||
if (!running && !settings.value && key === 'groupByConversation') hintBeforeChoice = groupHint.value
|
||||
choiceSeq += 1
|
||||
setKey(key, value)
|
||||
wanted.set(key, value)
|
||||
saved.value = false
|
||||
@@ -273,10 +315,12 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
|
||||
error,
|
||||
saved,
|
||||
pending,
|
||||
fetchSeq,
|
||||
knownUndoSendSeconds,
|
||||
groupByConversation,
|
||||
load,
|
||||
ensureLoaded,
|
||||
refresh,
|
||||
update,
|
||||
reset,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user