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

Merged
ttipo merged 21 commits from feat/mail-search-index into main 2026-11-08 13:30:13 +09:00
21 changed files with 668 additions and 53 deletions
Showing only changes of commit 438dac8e01 - Show all commits
@@ -4,11 +4,14 @@
// - 다른 메일: 단추(aria-expanded · 펼쳤을 때 aria-controls) — 누르면 그 자리에서 펼치고(ConversationMessagePanel) 다시 누르면 접는다.
// 펼친 안의 [다시 시도]가 사라지면 칸이 초점을 이 단추(data-conversation-toggle)로 돌려준다 (MessageConversation)
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
// - 안 읽은 메일(row.unread — 2026-11-07)은 목록 줄(MessageListItem)과 같은 굵기 · 색: 이름 650, 미리 보기 · 시각 600(목록 줄의 제목 · 시각 자리).
// 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, "(MAIL_LIST_ROW_TEXT). 지금 보는 메일의 줄도 같다
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ConversationMessagePanel from '@/components/mail/ConversationMessagePanel.vue'
import { MAIL_LIST_ROW_TEXT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import type { ConversationRow } from '@/shared/utils/mail-conversation'
import type { ConversationDetailState } from '@/stores/mail.store'
@@ -40,7 +43,7 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
<template>
<li
class="conversation-row"
:class="['conversation-row', { 'is-unread': row.unread }]"
:data-conversation-row="row.message.id"
>
<div
@@ -52,7 +55,12 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">{{ row.sender.name }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span class="conversation-row__current">{{ MAIL_CONVERSATION_TEXT.current }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
@@ -78,7 +86,12 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">{{ row.sender.name }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
@@ -200,8 +213,21 @@ button.conversation-row__line:focus-visible {
font-variant-numeric: tabular-nums;
}
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) */
.conversation-row__time.is-pending {
/* 안 읽음 — 뱃지 대신 굵기 · 색으로만, 목록 줄(MessageListItem)과 같은 값 (미리 보기가 목록 줄의 제목 자리다) */
.conversation-row.is-unread .conversation-row__who {
font-weight: 650;
color: var(--color-text);
}
.conversation-row.is-unread .conversation-row__preview,
.conversation-row.is-unread .conversation-row__time {
font-weight: 600;
color: var(--color-text);
}
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) — 안 읽음이어도 */
.conversation-row__time.is-pending,
.conversation-row.is-unread .conversation-row__time.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
@@ -37,7 +37,10 @@ const segments = computed(() => highlightSegments(props.text, props.ranges))
</template>
<style scoped>
/* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14) */
/*
* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14).
* 흐린 글자 위면 부르는 쪽이 글자색을 진하게 한다 — 검색 결과의 미리 보기 줄(MessageListItem — 대비 4.5:1, 2026-11-07)
*/
.search-mark {
padding: 0 var(--space-1);
border-radius: calc(var(--radius-xs) / 2);
@@ -465,22 +465,109 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디
expect(push).not.toHaveBeenCalled()
})
it('패널의 [조건 지우기] — 걸린 조건을 빼고 다시 찾는다. 검색어도 없으면 검색을 닫는다', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
await openPanel(wrapper)
describe('패널의 [조건 지우기] — 패널의 칸만 비운다 (2026-11-07 사용자 관점 점검)', () => {
const textValues = (wrapper: VueWrapper) =>
['from', 'to', 'subject', 'body'].map((name) => (field(wrapper, name).element as HTMLInputElement).value)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
it('칸 · 기간 · 날짜 · 첨부 체크를 비우고 패널은 열어 둔다 — 찾지 않고 초점은 그 단추에, 걸린 조건(주소 · [조건])은 그대로', async () => {
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&subject=${encodeURIComponent('견적')}&since=2026-10-01&until=2026-11-05&attachment=1`
const { wrapper, router, input } = await mountAt(path)
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await openPanel(wrapper)
expect(textValues(wrapper)).toEqual(['이도윤', '', '견적', ''])
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('custom')
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
const clear = wrapper.get<HTMLButtonElement>('[data-search-conditions-clear]')
clear.element.focus()
await clear.trigger('click')
await flushPromises()
await router.replace('/mail/search?in=inbox&attachment=1')
await flushPromises()
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(textValues(wrapper)).toEqual(['', '', '', ''])
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
expect(wrapper.find('[data-search-condition="since"]').exists()).toBe(false)
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(false)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
expect(document.activeElement).toBe(clear.element)
// 찾지 않는다 — 주소 · 검색 칸 · [조건]의 "조건이 걸려 있음"은 그대로
expect(router.currentRoute.value.fullPath).toBe(path)
expect(push).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
expect(input.element.value).toBe('회의')
expect(wrapper.get('[data-search-conditions-toggle]').text()).toContain('조건이 걸려 있음')
// 다시 눌러도 그대로다 (비운 칸)
await clear.trigger('click')
await flushPromises()
expect(textValues(wrapper)).toEqual(['', '', '', ''])
expect(document.activeElement).toBe(clear.element)
})
it('칸 아래 알림(날짜가 거꾸로)도 지운다', async () => {
const { wrapper } = await mountAt('/mail/inbox')
await openPanel(wrapper)
await field(wrapper, 'period').setValue('custom')
await field(wrapper, 'since').setValue('2026-11-05')
await field(wrapper, 'until').setValue('2026-10-01')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
})
it('[찾기] 없이 닫으면 아무것도 바뀌지 않는다 — 다시 열면 걸린 조건으로 다시 채운다', async () => {
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&attachment=1`
const { wrapper, router } = await mountAt(path)
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
await field(wrapper, 'to').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
expect(router.currentRoute.value.fullPath).toBe(path)
await openPanel(wrapper)
expect(textValues(wrapper)).toEqual(['이도윤', '', '', ''])
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(true)
})
it('비운 뒤 [찾기] — 검색어가 있으면 조건만 빼고 다시 찾고, 조건만 걸린 검색이면 검색을 닫는다(조건 줄의 [조건 지우기]와 같다)', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
await router.replace('/mail/search?in=inbox&attachment=1')
await flushPromises()
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
})
it('검색어도 조건도 없는 [찾기]는 조건이 걸리지 않았으면 아무것도 하지 않는다 — 패널은 그대로', async () => {
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
await input.setValue('')
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
})
})
})
+7 -10
View File
@@ -6,7 +6,8 @@
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
// - 칸 안 오른쪽(✕ 앞)의 [조건](MailSearchConditionsToggle)이 칸 바로 아래에 「조건으로 찾기」 패널을 연다 (14단계 — 열고 닫기는 useSearchConditionsPopup).
// 패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같고, 검색 칸의 Esc 는 패널만 닫는다(초점은 검색 칸에 남는다).
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다.
// 패널의 [조건 지우기]는 패널의 칸만 비운다(MailSearchConditions) — 걸린 조건은 [찾기]를 누르거나 결과의 조건 줄 [조건 지우기]로 뺀다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -20,7 +21,7 @@ import { hasSearchConditions, type SearchConditions } from '@/shared/utils/mail-
const PANEL_ID = 'mail-search-conditions'
const { isSearching, routeSearch, startSearch, clearConditions, closeSearch } = useMailSearchNavigation()
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
const text = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
@@ -68,7 +69,10 @@ function onKeydown(event: KeyboardEvent) {
}
}
/** [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면 패널을 닫고 검색 칸으로 (낱말이 너무 많으면 알림만 — 패널은 그대로) */
/**
* [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면(조건을 다 비워 검색을 닫았어도) 패널을 닫고 검색 칸으로
* (낱말이 너무 많으면 알림만 — 패널은 그대로)
*/
async function onApply(conditions: SearchConditions) {
if (!(await startSearch(text.value, conditions))) return
closeConditions({ restoreFocus: false })
@@ -76,12 +80,6 @@ async function onApply(conditions: SearchConditions) {
inputRef.value?.focus()
}
/** [조건 지우기] — 걸린 조건이 있으면 빼고 다시 찾는다(검색어도 없으면 검색을 닫는다). 패널을 닫고 [조건]으로 */
function onClear() {
closeConditions()
if (conditionsApplied.value) void clearConditions()
}
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
watch(
() => (routeSearch.value ? routeSearch.value.q : null),
@@ -145,7 +143,6 @@ watch(
:panel-id="PANEL_ID"
:applied="appliedConditions"
@apply="onApply"
@clear="onClear"
@close="closeConditions()"
/>
</form>
@@ -2,7 +2,8 @@
// 「조건으로 찾기」 패널의 칸 묶음 (14단계 — 디자인 원본 Search14): 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(MailSearchPeriodField) · 첨부.
// 이름 76 · 칸, 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다.
// 칸 값은 useSearchConditionsDraft — 처음 그릴 때 걸린 조건(applied)으로 채우고, 부르는 쪽이 take() 로 넘길 조건을 받는다
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점). 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점), clear() 로 칸을 모두 비운다([조건 지우기]).
// 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
import { nextTick, onMounted, ref } from 'vue'
import MailSearchPeriodField from '@/components/mail/MailSearchPeriodField.vue'
@@ -27,7 +28,7 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { draft, periodProblem, fill, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
const { draft, periodProblem, fill, clear, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
const TEXT_FIELDS: readonly { field: SearchConditionTextField; label: string; placeholder: string }[] = [
{ field: 'from', label: SEARCH_CONDITIONS_TEXT.from, placeholder: SEARCH_CONDITIONS_TEXT.fromPlaceholder },
@@ -58,6 +59,8 @@ async function takeConditions(): Promise<SearchConditions | null> {
defineExpose({
take: takeConditions,
/** 칸을 모두 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림도 (찾지 않는다) */
clear,
/** 첫 칸(보낸 사람)으로 */
focusFirst: () => rootRef.value?.querySelector<HTMLInputElement>('input')?.focus(),
})
@@ -3,7 +3,10 @@
// 칸 묶음(칸 값 · 기간 검사)은 MailSearchConditionFields. 단추 높이 40 — 칸과 같은 밀도 예외(--size-filter-control)
// - 열 때 지금 걸린 조건(applied)으로 칸을 채운다
// - [찾기] · 글 칸의 Enter: 조건을 apply 로 넘긴다. 날짜 고르기가 잘못됐으면(거꾸로 · 고를 수 없는 날짜) 넘기지 않는다 — 칸 아래에 알리고 그 칸으로
// - [조건 지우기]: clear 로 알린다 (부르는 쪽이 걸린 조건을 빼고 패널을 닫는다). Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
// - [조건 지우기]: 패널의 칸만 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림까지. 패널은 열린 채이고 찾지 않으며 초점은 그 단추에 남는다.
// 걸린 조건 · 조건 줄 · 결과는 [찾기]를 누를 때까지 그대로이고, [찾기] 없이 닫았다 다시 열면 걸린 조건으로 다시 채운다.
// 걸린 조건을 빼는 일은 결과의 조건 줄 [조건 지우기](MailSearchConditionLine)가 한다 (2026-11-07 사용자 관점 점검)
// - Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
import { computed, ref } from 'vue'
import MailSearchConditionFields from '@/components/mail/MailSearchConditionFields.vue'
@@ -20,7 +23,6 @@ interface Props {
interface Emits {
(e: 'apply', conditions: SearchConditions): void
(e: 'clear'): void
(e: 'close'): void
}
@@ -36,6 +38,11 @@ async function apply() {
if (conditions) emit('apply', conditions)
}
/** [조건 지우기] — 칸만 비운다 (머리말 — 찾지 않고, 패널과 초점은 그대로) */
function clearFields() {
fieldsRef.value?.clear()
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || isImeComposing(event)) return
event.preventDefault()
@@ -79,7 +86,7 @@ defineExpose({
type="button"
class="search-conditions__button"
data-search-conditions-clear
@click="emit('clear')"
@click="clearFields"
>
{{ SEARCH_CONDITIONS_TEXT.clear }}
</button>
@@ -104,6 +104,8 @@ function deferred() {
interface Server {
conversation?: (request: MockRequest) => MockResult | Promise<MockResult>
detail?: (request: MockRequest) => MockResult | Promise<MockResult>
/** 읽음 · 중요 표시 — 한 통(PATCH /messages/{id}) · 여러 통(PATCH /messages) */
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('MessageConversation — 읽기 화면의 같은 대화', () => {
@@ -132,7 +134,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
function serve(server: Server = {}) {
requests = installMockApi((request) => {
if (request.url.endsWith('/conversation')) return server.conversation?.(request) ?? page(THREAD)
if (request.method === 'PATCH') return ok({ ...REPLY, isRead: true })
if (request.method === 'PATCH') return server.patch?.(request) ?? ok({ ...REPLY, isRead: true })
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url.startsWith('/messages/')) {
if (server.detail) return server.detail(request)
@@ -218,8 +220,9 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
expect(mine.get('.conversation-row__preview').text()).toBe(ORIGINAL.preview)
expect(mine.get('time').text()).toBe('10월 27일 (화) 오후 2:10')
// 안 읽은 줄이다 — 화면 낭독용 "안 읽음, "이 이름 앞에 든다 (아래 "안 읽은 줄")
const other = rowOf(wrapper, 'm2')
expect(other.get('.conversation-row__who').text()).toBe('이도윤')
expect(other.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
expect(other.find('.conversation-row__mailbox').exists()).toBe(false)
expect(other.get('.conversation-row__avatar').classes()).not.toContain('conversation-row__avatar--mine')
expect(other.get('time').text()).toBe('10월 29일 (목) 오전 9:30')
@@ -453,6 +456,102 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
})
})
describe('안 읽은 줄 (2026-11-07 사용자 관점 점검)', () => {
it('안 읽은 줄은 목록 줄처럼 굵게(is-unread) — 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, ", 읽은 줄 · 임시 저장본은 아니다', async () => {
const draft = summary('d1', {
mailboxIds: ['drafts'],
from: { name: '김아라', email: USER.email },
isRead: false,
receivedAt: at(2026, 10, 6, 11, 0),
})
serve({ conversation: () => page([ORIGINAL, REPLY, CURRENT, draft]) })
const { wrapper } = await mountReader()
const unread = rowOf(wrapper, 'm2')
expect(unread.classes()).toContain('is-unread')
expect(unread.get('.conversation-row__who .visually-hidden').element.textContent).toBe('안 읽음, ')
// 펼침 단추의 이름에서 머리글자는 읽지 않으니(aria-hidden) "안 읽음, 이도윤 …"이 된다
expect(unread.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
expect(unread.get('.conversation-row__avatar').attributes('aria-hidden')).toBe('true')
for (const id of ['m1', 'm3', 'd1']) {
expect(rowOf(wrapper, id).classes()).not.toContain('is-unread')
expect(rowOf(wrapper, id).find('.conversation-row__who .visually-hidden').exists()).toBe(false)
}
})
it('펼쳐 읽음이 되면 바로 굵기를 푼다 — 읽음으로 바꾸지 못하면 다시 굵게', async () => {
const reply = deferred()
serve({ patch: () => reply.promise })
const { wrapper } = await mountReader()
const row = () => rowOf(wrapper, 'm2')
await row().get('button').trigger('click')
await flushPromises()
// 본문을 받았고 읽음을 보내는 중 — 줄은 이미 읽은 모양
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.url)).toEqual([
'/messages/m2',
])
expect(row().classes()).not.toContain('is-unread')
expect(row().find('.conversation-row__who .visually-hidden').exists()).toBe(false)
reply.resolve(fail(503, 'MAIL_001'))
await flushPromises()
expect(row().classes()).toContain('is-unread')
expect(row().get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
})
it('목록 · 여러 통 처리에서 바꾼 읽음 · 안 읽음도 줄에 바로 — 바꾸지 못한 메일은 줄도 되돌린다, 지금 보는 메일도 같다', async () => {
const first = deferred()
let firstBulk: Promise<MockResult> | null = first.promise
serve({
patch: (request) => {
if (request.url !== '/messages') return ok({ ...REPLY, isRead: true })
const reply = firstBulk ?? ok({ updatedIds: (request.data as { ids: string[] }).ids, failedIds: [] })
firstBulk = null
return reply
},
})
const { wrapper, store } = await mountReader()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 목록에서 골라 읽음 표시 — 보내는 동안 줄도 읽은 모양, 서버가 바꾸지 못했으면 다시 굵게
store.toggleCheck('m2')
const marking = store.patchChecked({ isRead: true })
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
first.resolve(ok({ updatedIds: [], failedIds: ['m2'] }))
await marking
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 지금 보는 메일을 안 읽음으로 — 그 줄도 굵게 ("지금 보고 있는 메일" 줄)
store.clearChecked()
store.toggleCheck('m3')
await store.patchChecked({ isRead: false })
await flushPromises()
const current = rowOf(wrapper, 'm3')
expect(current.classes()).toContain('is-unread')
expect(current.get('[aria-current="true"] .conversation-row__who').text()).toBe('안 읽음, 이도윤')
})
it('다른 탭 · 기기에서 바꾼 읽음은 다음에 칸을 다시 받을 때 보인다 — 목록을 맞춰도 줄은 그대로', async () => {
let thread = THREAD
serve({ conversation: () => page(thread) })
const { wrapper, store } = await mountReader()
// 다른 탭에서 m2 를 읽었다 — 목록 맞추기가 서버의 값을 가져왔다 (이 탭이 바꾼 것이 아니다)
thread = [ORIGINAL, { ...REPLY, isRead: true }, CURRENT]
store.messages = [{ ...REPLY, isRead: true }, CURRENT]
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 다시 받으면(새 메일 · 지움 · 옮김으로 통 수가 바뀜 · 쓰기 화면에서 돌아옴) 보인다
await store.refreshConversation()
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
})
})
describe('받지 못했을 때 — 읽기 화면은 그대로', () => {
it('칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] · 문의 코드 — 알리지 않는다, 누르면 다시 받는다', async () => {
let failing = true
@@ -2,6 +2,7 @@
// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화", 계약 2절 "대화 — 14단계")
// - 본문 · 첨부 아래 "같은 대화 N통" — N 은 total, 모르면 다 받았을 때의 받은 수, 그것도 모르면 수 없이 "같은 대화". 한 통뿐이면 칸이 없다
// - 테두리 상자 안의 줄(ConversationMessageRow — 48), 오래된 차례. 지금 보는 메일의 줄은 "지금 보고 있는 메일"이고 다른 줄은 그 자리에서 펼친다
// - 안 읽은 줄은 목록 줄처럼 굵게(2026-11-07). 이 탭에서 바꾼 읽음은 바로 바뀌고, 다른 탭 · 기기에서 바꾼 것은 다음에 받을 때 보인다(stores/mail/conversation.ts)
// - 받을 쪽이 남았으면 상자 아래 [더 보기](44) — 받는 동안 "불러오는 중…"(aria-busy, 초점을 잃지 않게 꺼짐 없이)
// - 받지 못하면 칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] — 읽기 화면은 그대로다. 없는 메일(RES_001)이면 칸 없이.
// 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다(나중에 생긴 영역은 읽어 주지 않는다)
@@ -337,6 +337,14 @@ function onMouseDown(event: MouseEvent) {
color: var(--color-text-muted);
}
/*
* 미리 보기 줄의 맞은 말만 글자색을 진하게 — 흐린 글자(#667085)는 노랑(#FCE7A4) 위에서 4.05:1 이라 4.5:1 에 못 미친다(#4A5363 은 6.3:1, 2026-11-07).
* 제목 줄 · 읽기 화면 · 본문 틀의 강조는 진한 글자 위라 글자색 그대로다 (HighlightedText)
*/
.message-row__preview :deep(.search-mark) {
color: var(--color-text-secondary);
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.message-row__who.is-placeholder,
.message-row__subject.is-placeholder {
@@ -33,7 +33,8 @@ function paramOf(value: unknown): string | null {
/**
* 검색 화면 오가기 (계약 2절 "검색" · "검색 — 14단계" · 디자인 README "3단계 › 검색" · "14단계 › 검색 · 조건 검색 · 강조")
* - 상단 검색 칸의 Enter · 「조건으로 찾기」의 [찾기]: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서),
* 검색 화면에서는 지금 범위 그대로. 검색 칸이 비어도 조건만으로 찾는다. Enter 는 걸려 있는 조건을 그대로 둔다
* 검색 화면에서는 지금 범위 그대로. 검색 칸이 비어도 조건만으로 찾는다. Enter 는 걸려 있는 조건을 그대로 둔다.
* 검색어도 패널의 조건도 없는 [찾기]는 조건이 걸린 검색이면 검색을 닫는다(조건 줄의 [조건 지우기]와 같다)
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다 (조건은 그대로)
* - 조건 줄의 [조건 지우기]: 조건만 빼고 다시 찾는다 — 검색어도 없으면 검색을 닫는다
* - ✕ · Esc · "검색 닫기": 검색 전 메일함(화면)으로 — 바로 앞 기록이 그곳이면 뒤로 가기 (뒤로 가기: 메일 → 결과 → 검색 전 메일함)
@@ -81,14 +82,20 @@ export function useMailSearchNavigation() {
/**
* Enter · [찾기] — 검색어도 조건도 없으면 아무것도 하지 않는다. 같은 검색으로 다시 누르면 보이는 채로 다시 찾는다.
* conditions 를 주면(「조건으로 찾기」) 그 조건으로, 주지 않으면(검색 칸의 Enter) 걸려 있는 조건 그대로 찾는다.
* 조건이 걸린 검색에서 패널의 칸을 모두 비우고 검색어도 없이 [찾기]를 누르면 조건 줄의 [조건 지우기]처럼 검색을 닫는다
* (2026-11-07 — 패널의 [조건 지우기]가 칸만 비우게 되어, 조건만 걸린 검색은 [찾기]로도 조건을 뺄 수 있게).
* 검색어와 조건 넷을 합친 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 친 글자는 그대로 둔다.
* 찾았으면 true (패널이 닫힌다)
* 찾았으면(검색을 닫았으면) true (패널이 닫힌다)
*/
async function startSearch(text: string, conditions?: Partial<SearchConditions> | null): Promise<boolean> {
const q = normalizeSearchText(text)
const current = routeSearch.value
const nextConditions = conditions === undefined ? (current?.conditions ?? null) : normalizeSearchConditions(conditions)
if (!q && !hasSearchConditions(nextConditions)) return false
if (!q && !hasSearchConditions(nextConditions)) {
if (!current || !hasSearchConditions(current.conditions)) return false
await closeSearch()
return true
}
if (exceedsCombinedSearchWords(q, nextConditions)) {
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
return false
@@ -27,7 +27,7 @@ export interface SearchConditionsDraft {
}
/**
* 「조건으로 찾기」 패널의 칸 값 (14단계 — 디자인 원본 Search14). 걸린 조건으로 채우고(fill), 넘길 조건을 만든다(take).
* 「조건으로 찾기」 패널의 칸 값 (14단계 — 디자인 원본 Search14). 걸린 조건으로 채우고(fill), 넘길 조건을 만든다(take), 모두 비운다(clear — [조건 지우기]).
* - 기간은 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기로 채운다. 미리 정한 기간은 넘길 때 그 날짜(그만큼 앞의 같은 날 ~ 오늘)로 바꾼다
* - 미리 정한 기간에서 날짜 고르기로 바꾸면 빈 날짜 칸을 그 기간의 날짜로 채워 둔다 — 조금만 고치면 되게
* - 날짜 고르기의 시작이 끝보다 늦거나 고를 수 없는 날짜면 넘기지 않고 periodProblem 을 둔다 (칸 아래에 알린다)
@@ -67,6 +67,14 @@ export function useSearchConditionsDraft(clock: Clock) {
periodProblem.value = null
}
/**
* 칸을 모두 비운다 — 패널의 [조건 지우기] (2026-11-07): 글 칸 · 기간(전체 기간) · 날짜 · 첨부 체크 · 칸 아래 알림까지.
* 찾지는 않는다 — 걸린 조건은 [찾기]를 누를 때까지 그대로다
*/
function clear() {
fill(null)
}
/** 기간 고르기를 바꾼다 */
function changePeriod(period: SearchPeriod) {
draft.period = period
@@ -115,5 +123,5 @@ export function useSearchConditionsDraft(clock: Clock) {
})
}
return { draft, periodProblem, fill, changePeriod, changeDay, take }
return { draft, periodProblem, fill, clear, changePeriod, changeDay, take }
}
@@ -19,6 +19,8 @@ import {
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import highlightedTextSource from '@/components/mail/HighlightedText.vue?raw'
import messageListItemSource from '@/components/mail/MessageListItem.vue?raw'
import MailboxPage from './MailboxPage.vue'
// 검색 결과 화면 (디자인 원본 Search · ListStates · 계약 2절 "검색")
@@ -369,6 +371,22 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(row.find('[aria-label="첨부 있음"]').exists()).toBe(true)
})
it('미리 보기 줄의 맞은 말만 글자색을 진하게(--color-text-secondary) — 흐린 글자 위 노랑은 대비가 모자라다, 제목 줄의 강조는 글자색 그대로 (2026-11-07)', () => {
// jsdom 은 컴포넌트의 style 을 적용하지 않는다 — 규칙을 원본에서 읽는다 (주석은 뺀다)
const rulesOf = (source: string) =>
[...source.slice(source.indexOf('<style')).replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^}]*)\}/g)].map(
([, selector = '', body = '']) => ({ selector: selector.replace('<style scoped>', '').trim(), body }),
)
// 강조 자체는 글자색을 물려받는다 — 제목 줄 · 읽기 화면 · 첨부 이름은 진한 글자 위다
const mark = rulesOf(highlightedTextSource).find((rule) => rule.selector === '.search-mark')
expect(mark?.body).toMatch(/color:\s*inherit;/)
// 목록 줄은 미리 보기 줄 안의 강조만 바꾼다 (제목 줄의 강조를 바꾸는 규칙은 없다)
const overrides = rulesOf(messageListItemSource).filter((rule) => rule.selector.includes('search-mark'))
expect(overrides.map((rule) => rule.selector)).toEqual(['.message-row__preview :deep(.search-mark)'])
expect(overrides[0]?.body).toMatch(/color:\s*var\(--color-text-secondary\);/)
})
it('강조가 없으면(엔진 방식 — 색인을 준비하는 중) 글 그대로 — 미리 보기는 메일의 미리 보기, 결과 머리 아래 "검색 준비 중" 줄', async () => {
handler = ({ url }) => {
if (url === '/search-index') return indexState('building')
@@ -466,6 +484,16 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(document.activeElement).toBe(wrapper.get('section.mail-list-frame').element)
})
it('조건만 걸린 검색에서 조건 줄의 [조건 지우기]는 검색을 닫는다 — 검색 전 메일함으로 (패널의 [조건 지우기]는 칸만 비운다 — 2026-11-07)', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?in=inbox&${CONDITIONS_QUERY}`)
await wrapper.get('[data-search-condition-line-clear]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('조건 줄의 [조건 지우기]를 누르면 줄이 사라진다 — 초점은 목록 칸으로', async () => {
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`, { attach: true })
const clear = wrapper.get('[data-search-condition-line-clear]')
+2 -1
View File
@@ -242,7 +242,8 @@ export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
export const MAIL_NO_SUBJECT = '(제목 없음)'
/**
* 목록 줄의 화면 낭독 · 아이콘 이름 — 대화 줄(14단계 — ConversationListItem · ConversationTableRow)이 쓴다.
* 목록 줄의 화면 낭독 · 아이콘 이름 — 대화 줄(14단계 — ConversationListItem · ConversationTableRow)과
* 같은 대화 칸의 줄(ConversationMessageRow — unreadPrefix, 2026-11-07)이 쓴다.
* 메일 줄(MessageListItem · MessageTableRow)도 같은 글이다 — 고칠 때는 함께
*/
export const MAIL_LIST_ROW_TEXT = {
@@ -254,6 +254,27 @@ describe('conversationRowsOf — 칸의 줄', () => {
expect(rows[0]?.location).toEqual({ name: 'mail-message', params: { mailboxId: 'sent', messageId: 'm1' } })
})
it('안 읽음은 목록 줄과 같은 규칙 — 안 읽은 메일이면 지금 메일 · 보낸편지함의 메일도, 임시 저장본은 읽음 여부를 보이지 않는다 (2026-11-07)', () => {
const rows = conversationRowsOf(
[
summary('read'),
summary('unread', { isRead: false }),
summary('sent', { mailboxIds: ['sent'], isRead: false }),
summary('draft', { mailboxIds: ['drafts'], isRead: false }),
summary('m3', { isRead: false }),
],
context,
)
expect(rows.map((row) => [row.message.id, row.unread])).toEqual([
['read', false],
['unread', true],
['sent', true],
['draft', false],
['m3', true],
])
})
it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중" — <time> 은 보낼 시각, 받은 줄 뒤에 둔다', () => {
const sendAt = '2026-10-30T00:00:00Z'
const rows = conversationRowsOf(
@@ -8,6 +8,8 @@
// 남는 글이 없으면 제목. 다른 메일 프로그램의 인용 꼴은 손대지 않는다 (2026-11-06 실사용 시험)
// - 줄의 차례: 서버 차례(받은 시각 — 내 메일은 저장한 시각) 그대로, 보내기를 기다리는 메일(pendingSend)만 받은 줄 뒤에 보낼 시각 차례로 —
// 예약한 메일이 그 뒤에 온 답장보다 앞에 서지 않게 (2026-11-06 실사용 시험)
// - 안 읽음: 목록 줄과 같은 규칙(mail-list-row.ts 의 isRowUnread — 임시 저장본은 읽음 여부를 보이지 않는다). 지금 보는 메일의 줄도 같다
// (2026-11-07 사용자 관점 점검 — 가운데 메일을 안 읽었으면 어느 것인지 알 수 없었다)
import type { RouteLocationRaw } from 'vue-router'
@@ -19,7 +21,7 @@ import type { Address, ConversationBasis, ConversationPage, Mailbox, MessageSumm
import type { PageInfo } from '@/shared/types/pagination'
import { formatConversationTime, initialOf } from '@/shared/utils/format'
import { displayName, normalizeEmail } from '@/shared/utils/mail-address'
import { mailboxOfMessage, pendingSendLabel } from '@/shared/utils/mail-list-row'
import { isRowUnread, mailboxOfMessage, pendingSendLabel } from '@/shared/utils/mail-list-row'
import { mailboxListIdOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
import { isScheduledMessage, messagePendingSend } from '@/shared/utils/pending-send'
@@ -214,6 +216,8 @@ export interface ConversationRow {
message: MessageSummary
/** 지금 읽기 화면에 연 메일 — "지금 보고 있는 메일", 누를 수 없다 */
current: boolean
/** 안 읽은 메일 — 목록 줄처럼 굵게, 화면 낭독은 이름 앞에 "안 읽음, " (임시 저장본은 늘 false) */
unread: boolean
sender: ConversationSender
/** 미리 보기 한 줄 — 비었으면 제목 */
preview: string
@@ -292,6 +296,7 @@ export function conversationRowsOf(items: readonly MessageSummary[], context: Co
return {
message,
current: message.id === context.currentId,
unread: isRowUnread(message, outgoingMailboxOf(message, context.mailboxes)?.role ?? null),
sender: conversationSenderOf(message, context),
preview: previewWithoutOwnQuote(message.preview).trim() || message.subject || MAIL_NO_SUBJECT,
time: pendingLabel ?? formatConversationTime(message.receivedAt, context.now, context.timeZone),
@@ -6,7 +6,7 @@ import {
MAIL_CONVERSATION_FILL_MAX_REQUESTS,
MAIL_CONVERSATION_PAGE_SIZE,
} from '@/shared/constants/mail-conversation'
import type { MessageDetail, MessageSummary } from '@/shared/types/mail'
import type { ConversationSummary, Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
@@ -715,4 +715,165 @@ describe('mail.store — 14단계 같은 대화', () => {
expect(store.mailboxes[0]?.unreadCount).toBe(1)
})
})
describe('이 탭이 바꾼 읽음 — 칸의 줄에 바로 (2026-11-07 사용자 관점 점검)', () => {
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 1 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const readOf = (store: ReturnType<typeof useMailStore>) => store.conversationItems.map((item) => [item.id, item.isRead])
function row(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? id),
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
messageCount: messageIds.length,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
it('받는 동안 이 탭이 바꾼 읽음은 받은 값보다 앞선다(받은 쪽은 바꾸기 전의 서버 값일 수 있다) — 그 뒤에 시작한 받기는 받은 값을 믿는다', async () => {
const unreadThread = () => [summary('m1'), summary('m2', { isRead: false }), summary('m3')]
let reply: MockResult | Promise<MockResult> = conversation(unreadThread())
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return reply
if (method === 'PATCH') return ok({ ...summary('m2'), isRead: true })
return ok(null)
})
const store = useMailStore()
await store.loadConversation('m3')
const stale = deferred()
reply = stale.promise
const refreshing = store.refreshConversation()
await store.markRead({ id: 'm2', isRead: false, mailboxIds: ['inbox'] })
expect(readOf(store)).toEqual([['m1', true], ['m2', true], ['m3', true]])
stale.resolve(conversation(unreadThread()))
await refreshing
expect(readOf(store)).toEqual([['m1', true], ['m2', true], ['m3', true]])
// 바꾼 뒤에 시작한 받기 — 그사이 다른 탭에서 안 읽음으로 했다
reply = conversation(unreadThread())
await store.refreshConversation()
expect(readOf(store)).toEqual([['m1', true], ['m2', false], ['m3', true]])
})
it('받기를 시작할 때 보내던 여러 통 처리의 읽음도 받은 값보다 앞선다 — 받는 사이에 끝나도', async () => {
let reply: MockResult | Promise<MockResult> = conversation([summary('m2', { isRead: false }), summary('m3')])
const bulk = deferred()
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return reply
if (method === 'PATCH' && url === '/messages') return bulk.promise
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [summary('m2', { isRead: false }), summary('m3')]
await store.loadConversation('m3')
store.toggleCheck('m2')
const marking = store.patchChecked({ isRead: true })
const stale = deferred()
reply = stale.promise
const refreshing = store.refreshConversation()
bulk.resolve(ok({ updatedIds: ['m2'], failedIds: [] }))
await marking
stale.resolve(conversation([summary('m2', { isRead: false }), summary('m3')]))
await refreshing
expect(readOf(store)).toEqual([['m2', true], ['m3', true]])
})
it('대화 목록의 여러 통 처리가 바꾸지 못한 메일은 칸도 바꾸기 전 값으로 — 줄은 메일마다의 앞 값을 몰라 칸이 아는 값으로', async () => {
const bulk = deferred()
serve(({ url, method }) => {
if (url.endsWith('/conversation')) {
return conversation([summary('m1', { mailboxIds: ['sent'] }), summary('m2', { isRead: false }), summary('m3', { isRead: false })])
}
if (method === 'PATCH' && url === '/messages') return bulk.promise
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'conversations', mailboxId: 'inbox' }
store.conversations = [row('c1', ['m3', 'm2'], { isRead: false })]
await store.loadConversation('m3')
store.toggleCheck('c1')
const marking = store.patchChecked({ isRead: true })
await flushPromises()
// 보내는 동안 — 줄의 메일은 읽은 모양, 보낸편지함의 m1 은 줄에 들지 않아 그대로
expect(readOf(store)).toEqual([['m1', true], ['m2', true], ['m3', true]])
bulk.resolve(ok({ updatedIds: ['m3'], failedIds: ['m2'] }))
await marking
expect(readOf(store)).toEqual([['m1', true], ['m2', false], ['m3', true]])
})
it('여러 통 처리의 중요 표시가 실패하면 중요 표시만 되돌린다 — 고른 때의 읽음으로 목록 · 칸을 덮지 않는다', async () => {
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return conversation([summary('m2'), summary('m3')])
if (method === 'PATCH' && url === '/messages') return ok({ updatedIds: [], failedIds: ['m2'] })
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
// 목록은 옛 값(안 읽음)이고 칸은 받은 값(읽음)이다 — 검색 결과처럼 저절로 맞추지 않는 목록
store.messages = [summary('m2', { isRead: false }), summary('m3')]
await store.loadConversation('m3')
store.toggleCheck('m2')
await store.patchChecked({ isFlagged: true })
expect(store.messages.find((message) => message.id === 'm2')).toMatchObject({ isRead: false, isFlagged: false })
expect(readOf(store)).toEqual([['m2', true], ['m3', true]])
})
it('메일함 ⋯ › 모두 읽음이 끝나면 칸에 든 그 메일함의 메일도 읽음 — 다른 메일함의 메일은 그대로', async () => {
serve(({ url, method }) => {
if (url.endsWith('/conversation')) {
return conversation([summary('m1', { mailboxIds: ['sent'], isRead: false }), summary('m2', { isRead: false }), summary('m3')])
}
if (method === 'PATCH' && url === '/mailboxes/inbox/messages') return ok({ processed: 1, remaining: 0 })
if (url === '/mailboxes') return ok({ items: [INBOX, SENT], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
return ok(null)
})
const store = useMailStore()
store.mailboxes = [INBOX, SENT]
await store.loadConversation('m3')
const outcome = await store.runMailboxOperation('read', INBOX, () => undefined)
expect(outcome.status).toBe('done')
expect(readOf(store)).toEqual([['m1', false], ['m2', true], ['m3', true]])
})
it('다른 탭 · 기기에서 바꾼 읽음(목록 맞추기로 받은 값)은 칸에 옮기지 않는다 — 다음에 칸을 받을 때 보인다', async () => {
let thread = [summary('m2', { isRead: false }), summary('m3')]
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return conversation(thread)
if (url === '/messages' && method === 'GET') {
return ok({ items: [summary('m2'), summary('m3')], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
}
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [summary('m2', { isRead: false }), summary('m3')]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
await store.loadConversation('m3')
await store.syncVisibleList()
expect(store.messages.find((message) => message.id === 'm2')?.isRead).toBe(true)
expect(readOf(store)).toEqual([['m2', false], ['m3', true]])
thread = [summary('m2'), summary('m3')]
await store.refreshConversation()
expect(readOf(store)).toEqual([['m2', true], ['m3', true]])
})
})
})
+2 -1
View File
@@ -88,7 +88,8 @@ export const useMailStore = defineStore('mail', () => {
.join('|')
})
// 통 수가 바뀌면 같은 대화 칸을 다시 받게 한다 — 읽음 · 중요 표시만 바뀐 소식으로는 받지 않는다(칸은 읽음을 그리지 않는다).
// 통 수가 바뀌면 같은 대화 칸을 다시 받게 한다 — 읽음 · 중요 표시만 바뀐 소식으로는 받지 않는다(그러면 메일을 열 때마다 다시 받는다.
// 칸의 안 읽은 줄은 이 탭에서 바꾼 읽음을 바로 적고 — conversation.ts — 다른 탭 · 기기에서 바꾼 읽음은 다음에 받을 때 보인다).
// 처음 받은 메일함 목록 · 비울 때(로그아웃)는 견줄 앞 값이 없어 건너뛴다. 칸이 없으면 소식만 오르고 아무도 받지 않는다
watch(mailboxTotalsKey, (next, previous) => {
if (next && previous) conversation.requestConversationRefresh()
+10 -2
View File
@@ -90,6 +90,8 @@ export function createBulkActions({ mailApi, mailboxes, list, selection, opened
list.endPending(ids, changes)
if (isStale()) {
// 다른 목록이라 목록은 되돌리지 않는다 — 같은 대화 칸의 읽음만 바꾸기 전으로 (14단계. 같은 목록이면 아래의 되돌림이 칸에도 알린다)
if (changes.isRead !== undefined) list.restoreLocalReads(new Set([...result.skippedIds, ...result.unsentIds]))
settleStale()
return { ...result, stale: true }
}
@@ -97,7 +99,11 @@ export function createBulkActions({ mailApi, mailboxes, list, selection, opened
const original = before.get(id)
if (!original) continue
flip(original, -1)
list.setLocalFlags(id, { isRead: original.isRead, isFlagged: original.isFlagged })
// 바꾼 표시만 되돌린다 — 중요 표시가 실패했을 때 읽음까지 고른 때의 값으로 적으면 그사이 읽은 것 · 같은 대화 칸의 값을 덮는다 (14단계)
const revert: FlagChanges = {}
if (changes.isRead !== undefined) revert.isRead = original.isRead
if (changes.isFlagged !== undefined) revert.isFlagged = original.isFlagged
list.setLocalFlags(id, revert)
}
if (changes.isRead !== undefined) void mailboxes.loadMailboxes({ background: true })
return { ...result, stale: false }
@@ -129,12 +135,14 @@ export function createBulkActions({ mailApi, mailboxes, list, selection, opened
return { done: patched.updatedIds, skipped: patched.failedIds }
})
list.endPending(ids, changes)
const failed = new Set([...result.skippedIds, ...result.unsentIds])
// 같은 대화 칸의 읽음을 바꾸기 전으로 — 줄은 메일마다의 앞 값을 몰라 칸이 아는 값으로, 목록이 바뀌었어도 (14단계)
if (changes.isRead !== undefined) list.restoreLocalReads(failed)
if (isStale()) {
settleStale()
return { ...result, stale: true }
}
const failed = new Set([...result.skippedIds, ...result.unsentIds])
if (failed.size > 0) {
list.restoreConversationFlags(originals.filter((row) => conversationActionIds(row).some((id) => failed.has(id))))
const shown = opened.selected.value
+106 -3
View File
@@ -16,6 +16,10 @@
// 같은 길(markRead — 목록 · 메일함 개수도 맞춘다)로 읽음. 이 탭이 아는 값(열린 메일 · 보이는 목록 · 이 칸이 읽음으로 바꾼 것)을 먼저 본다 —
// 받아 둔 대화가 옛 값이어도 같은 메일을 두 번 읽음으로 바꾸지 않게. 받아 둔 본문은 접힌 줄의 것을 최근 MAIL_CONVERSATION_DETAIL_CACHE_MAX 개만 둔다.
// 그사이 지워진 메일(본문이 404 RES_001)은 대화를 다시 받아 그 줄을 뺀다([더 보기]로 더 받은 대화는 그 줄만 뺀다)
// 이 탭이 바꾼 읽음 (2026-11-07 — 칸이 안 읽은 줄을 굵게 그린다): 목록 · 열린 메일에 읽음을 적을 때마다(list.ts 의 watchLocalReads — 펼친 줄 · 읽기 화면에서
// 연 메일 · 목록과 여러 통 처리의 읽음 · 안 읽음 표시 · 실패해 되돌림 · 서버가 돌려준 요약 · 메일함 ⋯ › 모두 읽음) 칸의 줄에도 바로 적는다.
// 받는 동안 바꾼 것 · 받기를 시작할 때 보내던 것은 받은 값보다 이 탭의 값을 믿는다 — 받은 쪽은 그 바꿈이 서버에 닿기 전의 값일 수 있다.
// 다른 탭 · 기기에서 바꾼 읽음은 다음에 칸을 받을 때 보인다 (읽음만 바뀐 소식으로는 다시 받지 않는다 — mail.store.ts)
import { computed, ref, shallowRef } from 'vue'
import { toApiError, type ApiError } from '@/composables/useApi'
@@ -50,6 +54,19 @@ interface Gathered {
pageInfo: PageInfo
}
/** 이 탭이 바꾼 읽음 하나 — 마지막으로 적은 값 · 그때의 차례 · 바꾸기 전에 칸이 알던 값(모르면 null — 되돌릴 때 쓴다) */
interface LocalRead {
isRead: boolean
seq: number
before: boolean | null
}
/** 받기를 시작할 때 — 그때의 차례와 그때 읽음을 보내던 메일 */
interface LocalReadMark {
seq: number
sending: ReadonlySet<string>
}
interface ConversationDeps {
mailApi: MailApi
list: ListState
@@ -124,6 +141,12 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
let refreshAgain = false
/** 이 칸이 읽음으로 바꿨거나 읽기 화면에서 본 메일 — 받아 둔 대화가 옛 값이어도 읽음으로 본다 */
const markedRead = new Set<string>()
/**
* 이 탭이 바꾼 읽음 (머리말) — 화면에 그리지 않으므로 반응형이 아니다. 받기를 시작할 때 그때 보내던 것만 남기고 잊는다(그 뒤 받은 값이 담는다),
* 비울 때(reset) 모두 잊는다 — 메일 id 는 계정마다 겹칠 수 있다
*/
const localReads = new Map<string, LocalRead>()
let localReadSeq = 0
/** 받는 일 하나의 확인 — 그 뒤 다른 일이 시작됐거나 · 다른 대화 · 세션이 바뀌었으면 false */
function liveCheck(messageId: string): () => boolean {
@@ -147,9 +170,87 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
}
}
// ── 이 탭이 바꾼 읽음 (머리말) ─────────────────────────────
/** 칸의 줄에 읽음을 적는다 — 바뀌는 줄이 없으면 그대로 둔다 */
function writeReads(reads: ReadonlyMap<string, boolean>) {
const items = conversationItems.value
if (!items.some((item) => reads.has(item.id) && reads.get(item.id) !== item.isRead)) return
conversationItems.value = items.map((item) => {
const isRead = reads.get(item.id)
return isRead === undefined || isRead === item.isRead ? item : { ...item, isRead }
})
}
/** 목록 · 열린 메일에 읽음을 적었다 — 칸의 줄에도 적고, 바꾼 차례와 바꾸기 전 값을 둔다 */
function noteLocalRead(ids: Iterable<string>, isRead: boolean) {
localReadSeq += 1
const shown = new Map(conversationItems.value.map((item) => [item.id, item.isRead]))
const reads = new Map<string, boolean>()
for (const id of ids) {
localReads.set(id, { isRead, seq: localReadSeq, before: shown.get(id) ?? localReads.get(id)?.isRead ?? null })
reads.set(id, isRead)
}
writeReads(reads)
}
/** 바꾸지 못한 메일 — 칸이 알던 앞 값으로 되돌린다. 앞 값을 모르면 이 탭의 값을 잊는다(다음에 받은 값을 믿는다) */
function restoreLocalRead(ids: Iterable<string>) {
localReadSeq += 1
const reads = new Map<string, boolean>()
for (const id of ids) {
const local = localReads.get(id)
if (!local) continue
if (local.before === null) {
localReads.delete(id)
continue
}
localReads.set(id, { isRead: local.before, seq: localReadSeq, before: local.isRead })
reads.set(id, local.before)
}
writeReads(reads)
}
/** 메일함 ⋯ › 모두 읽음이 끝났다 — 칸에 든 그 메일함의 안 읽은 메일을 읽음으로 */
function noteMailboxRead(mailboxId: string) {
const ids = conversationItems.value
.filter((item) => !item.isRead && item.mailboxIds.includes(mailboxId))
.map((item) => item.id)
if (ids.length > 0) noteLocalRead(ids, true)
}
list.watchLocalReads({ changed: noteLocalRead, restored: restoreLocalRead, mailboxRead: noteMailboxRead })
/**
* 받기를 시작한다 — 지금 차례와 지금 읽음을 보내는 메일을 적어 두고, 나머지는 잊는다
* (그 바꿈은 서버에 닿았다 — 이제 받는 값에 들어 있다)
*/
function markLocalReads(): LocalReadMark {
const sending = new Set<string>()
for (const id of [...localReads.keys()]) {
if (list.pendingFlagsOf(id)?.isRead !== undefined) sending.add(id)
else localReads.delete(id)
}
return { seq: localReadSeq, sending }
}
/**
* 받은 쪽에 이 탭이 아는 읽음을 덮는다 — 지금 읽음을 보내는 중이면 그 값, 받기를 시작한 뒤 바꿨거나 그때 보내던 메일이면 이 탭이 적은 값
* (받은 값은 그 바꿈이 서버에 닿기 전의 값일 수 있다). 그 밖은 받은 값이다 — 다른 탭 · 기기에서 바꾼 읽음도 이때 보인다
*/
function withLocalReads(items: readonly MessageSummary[], mark: LocalReadMark): MessageSummary[] {
return items.map((item) => {
const local = localReads.get(item.id)
const known = local && (local.seq > mark.seq || mark.sending.has(item.id)) ? local.isRead : undefined
const isRead = list.pendingFlagsOf(item.id)?.isRead ?? known ?? item.isRead
return isRead === item.isRead ? item : { ...item, isRead }
})
}
/**
* 쪽을 받아 모은다 — from 이 없으면 처음 쪽부터, 있으면 그 다음 쪽부터. 새로 한 쪽이 차거나 · 더 없거나 · 요청이 FILL_MAX 번이 될 때까지.
* 쪽마다 onPage(지금까지 모은 것, 더 묻는지). 그사이 다른 일이 시작됐으면 null — 받은 것을 쓰지 않는다. 실패는 그대로 던진다
* 쪽마다 onPage(지금까지 모은 것, 더 묻는지). 그사이 다른 일이 시작됐으면 null — 받은 것을 쓰지 않는다. 실패는 그대로 던진다.
* 받은 쪽에는 이 탭이 받는 동안 바꾼 읽음을 덮는다 (withLocalReads)
*/
function gather(
messageId: string,
@@ -161,6 +262,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
let cursor = from ? nextConversationCursor(from.pageInfo) : null
if (from && cursor === null) return Promise.resolve(from)
return tracked(async () => {
const mark = markLocalReads()
let items: MessageSummary[] = from ? [...from.items] : []
let pageInfo: PageInfo | null = from?.pageInfo ?? null
let added = 0
@@ -175,7 +277,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
requests += 1
if (!live()) return null
const merged = appendConversationPage(items, pageInfo, page)
items = merged.items
items = withLocalReads(merged.items, mark)
pageInfo = merged.pageInfo
added += merged.added
cursor = nextConversationCursor(pageInfo)
@@ -396,7 +498,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
// ── 펼친 줄 ─────────────────────────────────────────
function setItemRead(id: string, isRead: boolean) {
conversationItems.value = conversationItems.value.map((item) => (item.id === id ? { ...item, isRead } : item))
writeReads(new Map([[id, isRead]]))
}
/** 이 탭이 아는 읽음 — 열린 메일 · 보이는 목록 · 이 칸이 읽음으로 바꾼 것, 모르면 받아 둔 대화의 값 */
@@ -490,6 +592,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
refreshing = null
refreshAgain = false
markedRead.clear()
localReads.clear()
}
return {
+41 -2
View File
@@ -14,6 +14,10 @@
// - localGeneration: 이 탭이 목록 · 열린 메일을 바꿀 때마다(읽음 · 중요 표시 · 빼기 · 옮기기 · 보내던 표시가 끝남) —
// 그 전에 떠난 새 메일 확인의 응답은 옛 상태라 버리고 한 번 더 맞춘다 (옮긴 메일이 되살아나거나 표시가 되돌아가지 않게)
//
// 같은 대화 칸에 알리기 (14단계 — 2026-11-07): 이 탭이 읽음을 적을 때마다(setFlagsOf · applySummary) 같은 대화 칸(conversation.ts)이
// 듣는다(watchLocalReads) — 칸의 안 읽은 줄을 굵게 그리고, 칸은 읽음만 바뀐 소식으로 다시 받지 않기 때문이다.
// 여러 통 처리가 바꾸지 못한 메일(restoreLocalReads)과 메일함 ⋯ › 모두 읽음이 끝남(noteMailboxRead)도 알린다
//
// 검색 결과 이어 받기 (14단계 — 계약 2절 "검색 — 14단계"): 서버(색인 방식)는 한 요청에 확인하는 메일 수에 상한이 있어 hasNext 인데 항목이
// 적거나 0개인 쪽을 준다. 검색을 열 때 · 더 볼 때 한 쪽(20통)이 찰 때까지 커서로 이어 묻는다(MAIL_SEARCH_FILL_MAX_REQUESTS 번까지 — 넘으면
// 사용자가 [더 찾기] · [메일 더 보기]로 잇는다). 하나도 찾기 전에는 "찾는 중"(loading), 하나라도 찾으면 보이며 뒤에서 잇는다(isLoadingMore).
@@ -67,6 +71,16 @@ import type { FlagChanges, LoadStatus, MailApi, SearchOrigin } from './types'
/** 목록 · 열린 메일에 바로 적는 표시 */
type LocalChanges = Partial<Pick<MessageSummary, 'isRead' | 'isFlagged' | 'mailboxIds'>>
/** 같은 대화 칸이 듣는 이 탭의 읽음 (머리말 — watchLocalReads, conversation.ts) */
export interface LocalReadListener {
/** 그 메일들의 읽음을 이 값으로 적었다 — 먼저 바꿈 · 실패해 되돌림 · 서버가 돌려준 요약 */
changed: (ids: ReadonlySet<string>, isRead: boolean) => void
/** 그 메일들의 읽음을 바꾸기 전 값으로 — 여러 통 처리가 바꾸지 못한 메일 (메일마다의 앞 값은 칸이 안다) */
restored: (ids: ReadonlySet<string>) => void
/** 그 메일함의 메일을 모두 읽음으로 했다 — 메일함 ⋯ › 모두 읽음이 끝남 */
mailboxRead: (mailboxId: string) => void
}
interface ListDeps {
mailApi: MailApi
mailboxes: MailboxesState
@@ -145,6 +159,8 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
* 화면에 그리지 않으므로 반응형이 아니다
*/
const pendingFlags = new Map<string, FlagChanges>()
/** 같은 대화 칸 — 하나만 듣는다 (watchScheduledTotal 과 같은 결) */
let localReadListener: LocalReadListener | null = null
// 출처가 바뀌면(어떻게 바꿨든) 그 수를 세고 알림 줄 정보를 비운다
watch(
@@ -182,7 +198,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
/**
* 여러 메일의 표시를 한 번에 — 목록 · 열린 메일 · 대화 줄(그 메일이 든 줄 — 줄의 메일을 모두 바꿨을 때만 줄 전체가 읽음 · 중요 해제다,
* mail-conversation-list.ts 의 withConversationFlags)
* mail-conversation-list.ts 의 withConversationFlags). 읽음이면 같은 대화 칸에도 알린다
*/
function setFlagsOf(ids: ReadonlySet<string>, changes: LocalChanges) {
if (ids.size === 0) return
@@ -193,6 +209,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
const current = opened.selected.value
if (current && ids.has(current.id)) opened.selected.value = { ...current, ...changes }
bumpLocalChange()
if (changes.isRead !== undefined) localReadListener?.changed(ids, changes.isRead)
}
/** 대화 줄의 읽음 · 중요 표시를 앞 값으로 되돌린다 — 보내지 못한 메일이 든 줄 (여러 통 처리 — bulk.ts) */
@@ -208,7 +225,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
/**
* 서버가 돌려준 요약으로 목록 · 열린 메일을 맞춘다 — 요약(PATCH 응답)에는 검색 강조가 없으므로 목록 줄의 강조는 그대로 둔다 (14단계).
* 대화 줄은 그 메일이 마지막 메일이면 바꿔 끼우고, 표시는 setFlagsOf 와 같은 규칙으로 적는다
* 대화 줄은 그 메일이 마지막 메일이면 바꿔 끼우고, 표시는 setFlagsOf 와 같은 규칙으로 적는다. 읽음은 같은 대화 칸에도 알린다
*/
function applySummary(summary: MessageSummary) {
messages.value = messages.value.map((message) => {
@@ -225,6 +242,25 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
}
if (opened.selected.value?.id === summary.id) opened.selected.value = { ...opened.selected.value, ...summary }
bumpLocalChange()
localReadListener?.changed(new Set([summary.id]), summary.isRead)
}
/** 같은 대화 칸이 이 탭의 읽음을 듣는다 (머리말) */
function watchLocalReads(listener: LocalReadListener) {
localReadListener = listener
}
/**
* 여러 통 처리가 바꾸지 못한 메일 — 같은 대화 칸의 읽음을 바꾸기 전으로 (bulk.ts). 대화 목록의 줄은 메일마다의 앞 값을 몰라
* (restoreConversationFlags 는 줄째 되돌린다) 칸이 아는 앞 값으로, 끝났을 때 목록이 바뀌어 있었으면 목록 대신 칸만
*/
function restoreLocalReads(ids: ReadonlySet<string>) {
if (ids.size > 0) localReadListener?.restored(ids)
}
/** 메일함 ⋯ › 모두 읽음이 끝났다 — 같은 대화 칸에 든 그 메일함의 메일도 읽음으로 (mailbox-admin.ts — 목록은 서버 기준으로 다시 맞춘다) */
function noteMailboxRead(mailboxId: string) {
localReadListener?.mailboxRead(mailboxId)
}
/** 목록에서 뺀다 — 전체 개수도 그만큼 줄인다 (대화 줄은 메일이 모두 빠진 줄만 빠지고 그 수를 센다) */
@@ -763,6 +799,9 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
setFlagsOf,
restoreConversationFlags,
applySummary,
watchLocalReads,
restoreLocalReads,
noteMailboxRead,
removeFromList,
relocate,
beginPending,
@@ -142,6 +142,8 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
*/
function settle(kind: MailboxOperationKind, mailboxId: string) {
mailboxes.syncCounts()
// 모두 읽음 — 같은 대화 칸에 든 그 메일함 메일도 읽음으로 (14단계 — 칸은 읽음만 바뀐 소식으로 다시 받지 않는다)
if (kind === 'read') list.noteMailboxRead(mailboxId)
const source = list.listSource.value
if (kind === 'delete' || !source || scopeMailboxIdOf(source) !== mailboxId) return
if (kind === 'read') void list.syncVisibleList({ includeSearch: true })