generated from ttipo/project-base
fix(frontend): 같은 대화 칸의 안 읽은 줄을 굵게 · 패널 [조건 지우기]는 칸만 비운다
2026-11-07 사용자가 사용자 관점 점검의 보드 바꾸기 제안을 추천대로 정했다. - 같은 대화 칸: 안 읽은 줄을 목록 줄처럼 굵게 그리고 화면 낭독에 "안 읽음, "을 붙인다. 이 탭이 바꾼 읽음(펼침 · 읽기 화면 · 목록 · 여러 통 처리 · 모두 읽음)은 줄에 바로 적고, 받는 동안 바꾼 것은 받은 값보다 앞세운다. 읽음만 바뀐 소식으로는 다시 받지 않는다(메일을 열 때마다 다시 받게 되므로). - 여러 통 처리가 실패하면 바꾼 표시만 되돌린다(중요 표시 실패가 읽음을 덮지 않게). - 조건 패널의 [조건 지우기]는 칸만 비우고 패널을 열어 둔다(찾지 않는다). 조건만 걸린 검색에서 비운 채 [찾기]를 누르면 검색을 닫는다. - 검색 결과 미리 보기 줄의 강조는 글자를 --color-text-secondary 로 진하게 한다 (노랑 위 대비 4.05:1 → 6.3:1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]')
|
||||
|
||||
@@ -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]])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,7 +88,8 @@ export const useMailStore = defineStore('mail', () => {
|
||||
.join('|')
|
||||
})
|
||||
|
||||
// 통 수가 바뀌면 같은 대화 칸을 다시 받게 한다 — 읽음 · 중요 표시만 바뀐 소식으로는 받지 않는다(칸은 읽음을 그리지 않는다).
|
||||
// 통 수가 바뀌면 같은 대화 칸을 다시 받게 한다 — 읽음 · 중요 표시만 바뀐 소식으로는 받지 않는다(그러면 메일을 열 때마다 다시 받는다.
|
||||
// 칸의 안 읽은 줄은 이 탭에서 바꾼 읽음을 바로 적고 — conversation.ts — 다른 탭 · 기기에서 바꾼 읽음은 다음에 받을 때 보인다).
|
||||
// 처음 받은 메일함 목록 · 비울 때(로그아웃)는 견줄 앞 값이 없어 건너뛴다. 칸이 없으면 소식만 오르고 아무도 받지 않는다
|
||||
watch(mailboxTotalsKey, (next, previous) => {
|
||||
if (next && previous) conversation.requestConversationRefresh()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user