generated from ttipo/project-base
feat(frontend): 보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단 · 보내기 · 읽기 설정 화면
쓰기 화면의 [보내기 ▾](예약 발송 창), 보내기 취소 알림(남은 시간 막대), 메일함 패널 「예약됨」 · 예약 목록, 읽기 화면의 안내 줄([이미지 보기] · [고치기] · [예약 취소] · [차단 풀기]), 인쇄 화면(새 탭 — A4 폭으로 다시 재서 빈 쪽 없이), 보낸 사람 차단 창, 설정 › 보내기 · 읽기 · 차단한 주소. 디자인 README "9단계", 계약 2절. 리뷰로 고친 것: - 붙잡힌 메일을 여러 통 처리로 지워도 예약이 그대로 나갔다 — 고르지 못하게 했다(고르기 칸 aria-disabled + 까닭, 하나 · 범위 · 모두 고르기가 건너뛴다). 읽기 화면 [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 — 서버도 MAIL_015 로 막는다. - 엔진이 발송을 풀어도 상태가 바뀌지 않아 "보내는 중" · 「예약됨」이 남았다 — 보낼 시각 + 2초에 목록 · 열린 메일 · 예약 수를 다시 맞춘다(MAIL_014 때도). - 설정을 연달아 고르면 늦게 온 앞 응답이 화면과 서버를 갈랐다 — 칸마다 저장은 한 번에 하나, 끝나면 마지막으로 고른 값으로 맞춘다. - 응답을 기다리는 사이 다른 목록으로 간 사용자를 옛 목록의 다음 메일로 끌고 갔다 — 같은 목록 · 같은 메일을 보고 있을 때만 옮긴다(3 · 4단계 단추도). - 이미지를 보일 때 본문 틀의 CSP 를 우리 출처의 중계 경로로만 연다 — 'self' 면 정화를 빠져나간 CSS url() 이 다른 API 를 부를 수 있었다. - 알림 단추는 한 번만 돈다, 보내기 취소 알림의 마감은 설정한 초로 묶는다, [보내기 ▾] 메뉴는 화면 안에서 연다(쓰기 화면이 움직이지 않게), 차단 목록 · 설정 저장의 상태가 멈추던 것, 초점을 잃던 것과 알리지 않던 라이브 영역. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
// 알림 아래의 남은 시간 막대 (디자인 원본 MailSendLater "보낸 뒤 알림" — 높이 3 · 밝은 청록) — 마감 시각이 있는 알림(toast.store 의 expiresAt)에만
|
||||
// 시간은 마감 시각까지 줄어들고, 마우스를 올려도 멈추지 않는다(알림도 그 시각에 닫힌다). 남은 시간을 글로 읽어 주지는 않는다 — 장식이다(aria-hidden).
|
||||
// 막대는 인라인 스타일 대신 <progress> 를 꾸민다 (첨부 올리기 막대와 같은 결 — ComposeAttachmentList)
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
/** 띄운 시각(ms — Date.now) — 막대의 처음 길이 */
|
||||
startsAt: number
|
||||
/** 닫히는 시각(ms — Date.now) */
|
||||
endsAt: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
/** 막대를 다시 그리는 간격 — 30초 막대도 매끄럽게 줄어 보이는 만큼 */
|
||||
const TICK_MS = 100
|
||||
|
||||
const now = ref<number>(Date.now())
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const total = computed(() => Math.max(1, props.endsAt - props.startsAt))
|
||||
const remaining = computed(() => Math.min(total.value, Math.max(0, props.endsAt - now.value)))
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, TICK_MS)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<progress
|
||||
class="toast-countdown"
|
||||
aria-hidden="true"
|
||||
:max="total"
|
||||
:value="remaining"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 높이 3 · 안쪽 오른쪽 여백 8 (디자인 원본 MailSendLater) — 알림의 둘째 줄을 다 채운다 */
|
||||
.toast-countdown {
|
||||
appearance: none;
|
||||
display: block;
|
||||
flex: 0 0 100%;
|
||||
width: calc(100% - var(--space-8));
|
||||
height: var(--space-3);
|
||||
margin: var(--space-2) 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--space-2);
|
||||
background: var(--toast-countdown-track);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-countdown::-webkit-progress-bar {
|
||||
background: var(--toast-countdown-track);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.toast-countdown::-webkit-progress-value {
|
||||
background: var(--toast-countdown-bar);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.toast-countdown::-moz-progress-bar {
|
||||
background: var(--toast-countdown-bar);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import ToastHost from './ToastHost.vue'
|
||||
|
||||
// 화면 오른쪽 아래 알림 — 단추는 한 번만 움직이고(리뷰 #9), 초점이 든 알림이 닫히면 초점을 돌려준다(리뷰 #8)
|
||||
|
||||
describe('ToastHost', () => {
|
||||
let pinia: Pinia
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
useToastStore().clear()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
/** 화면 — main 안의 입력칸 하나 + 알림 틀 */
|
||||
async function mountHost() {
|
||||
const main = document.createElement('main')
|
||||
const field = document.createElement('input')
|
||||
main.append(field)
|
||||
document.body.append(main)
|
||||
wrapper = mount(ToastHost, { attachTo: document.body, global: { plugins: [pinia, createTestRouter()] } })
|
||||
await flushPromises()
|
||||
return { main, field }
|
||||
}
|
||||
|
||||
function showUndo(onRun: () => void, expiresAt?: number): number {
|
||||
return useToastStore().show({
|
||||
kind: 'success',
|
||||
message: '메일을 보냈습니다.',
|
||||
action: { label: '보내기 취소', run: onRun },
|
||||
expiresAt: expiresAt ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const actionButton = () => document.querySelector<HTMLButtonElement>('.toast__action')
|
||||
|
||||
it('단추를 누르면 알림을 닫고 할 일을 한 번 한다 — 닫히는 동안 한 번 더 눌러도 또 하지 않는다', async () => {
|
||||
await mountHost()
|
||||
let runs = 0
|
||||
showUndo(() => {
|
||||
runs += 1
|
||||
})
|
||||
await flushPromises()
|
||||
const button = actionButton()
|
||||
if (!button) throw new Error('단추가 없다')
|
||||
|
||||
button.click()
|
||||
// 퇴장 전환 동안 남아 있는 같은 단추를 한 번 더 누른다
|
||||
button.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(runs).toBe(1)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('초점이 든 알림이 닫히면(마감 시각 · 시간이 다 됨) 알림에 들어오기 전 자리로 초점을 돌려준다', async () => {
|
||||
const { field } = await mountHost()
|
||||
const id = showUndo(() => undefined, Date.now() + 10_000)
|
||||
await flushPromises()
|
||||
field.focus()
|
||||
actionButton()?.focus()
|
||||
expect(document.activeElement).toBe(actionButton())
|
||||
|
||||
// 마감이 있는 알림은 초점이 있어도 닫힌다 (그 시각이 지나면 되돌릴 수 없다)
|
||||
useToastStore().dismiss(id)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(field)
|
||||
})
|
||||
|
||||
it('그 자리가 사라졌으면 화면의 main 으로 — 초점을 받게 잠깐 단 tabindex 는 초점이 떠나면 뗀다', async () => {
|
||||
const { main, field } = await mountHost()
|
||||
const id = showUndo(() => undefined)
|
||||
await flushPromises()
|
||||
field.focus()
|
||||
actionButton()?.focus()
|
||||
field.remove()
|
||||
|
||||
useToastStore().dismiss(id)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(main)
|
||||
expect(main.getAttribute('tabindex')).toBe('-1')
|
||||
expect(main.hasAttribute('data-focus-fallback')).toBe(true)
|
||||
|
||||
main.blur()
|
||||
expect(main.hasAttribute('tabindex')).toBe(false)
|
||||
expect(main.hasAttribute('data-focus-fallback')).toBe(false)
|
||||
})
|
||||
|
||||
it('단추를 눌러 닫혀도 초점을 돌려준다 — 할 일이 초점을 옮기면 그쪽이 이긴다', async () => {
|
||||
const { field } = await mountHost()
|
||||
const other = document.createElement('button')
|
||||
document.body.append(other)
|
||||
showUndo(() => other.focus())
|
||||
await flushPromises()
|
||||
field.focus()
|
||||
actionButton()?.focus()
|
||||
|
||||
actionButton()?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(other)
|
||||
})
|
||||
|
||||
it('초점이 알림 밖에 있으면 닫혀도 초점을 건드리지 않는다', async () => {
|
||||
const { field } = await mountHost()
|
||||
const id = showUndo(() => undefined)
|
||||
await flushPromises()
|
||||
field.focus()
|
||||
|
||||
useToastStore().dismiss(id)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(field)
|
||||
})
|
||||
|
||||
it('알림 사이를 옮겨 다녀도 처음 들어온 자리를 기억한다', async () => {
|
||||
const { field } = await mountHost()
|
||||
const first = showUndo(() => undefined)
|
||||
const second = showUndo(() => undefined)
|
||||
await flushPromises()
|
||||
const buttons = () => [...document.querySelectorAll<HTMLButtonElement>('.toast__action')]
|
||||
field.focus()
|
||||
buttons()[0]?.focus()
|
||||
buttons()[1]?.focus()
|
||||
|
||||
useToastStore().dismiss(second)
|
||||
await flushPromises()
|
||||
expect(document.activeElement).toBe(field)
|
||||
useToastStore().dismiss(first)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,14 @@
|
||||
// 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
|
||||
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
|
||||
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
|
||||
// 마감 시각이 있는 알림(9단계 [보내기 취소])은 둘째 줄에 남은 시간 막대(ToastCountdown) — 그 시각에 닫히고 붙잡아도 멈추지 않는다
|
||||
// 초점이 든 알림이 닫히면(시간이 다 됨 — 마감이 있는 알림 포함 · 단추 · 넘침 · 세션이 바뀜) 초점을 알림에 들어오기 전 자리로 돌려준다 —
|
||||
// 그 요소가 사라졌으면 화면의 main 으로 (키보드 사용자가 자리를 잃고 body 로 빠지지 않게 — 2026-10-29 리뷰)
|
||||
// 단추는 한 번만 움직인다 — 닫히는 동안(퇴장 전환) 다시 눌러도 할 일을 또 하지 않는다 (toast.store 의 takeAction)
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ToastCountdown from '@/components/feedback/ToastCountdown.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { formatTraceCode } from '@/shared/utils/format'
|
||||
import { useToastStore, type Toast, type ToastKind } from '@/stores/toast.store'
|
||||
@@ -18,6 +25,13 @@ const TOAST_ICONS: Readonly<Record<ToastKind, { name: IconName; size: number; st
|
||||
progress: { name: 'loader', size: 16, strokeWidth: 2 },
|
||||
}
|
||||
|
||||
/** 초점을 잃었을 때 대신 받는 화면의 main — tabindex 가 없으면 잠깐 단다(초점이 떠나면 뗀다) */
|
||||
const MAIN_FALLBACK_ATTR = 'data-focus-fallback'
|
||||
|
||||
const hostRef = ref<HTMLElement | null>(null)
|
||||
/** 알림으로 초점이 들어오기 전 초점이 있던 요소 — 알림 사이를 옮겨 다닌 것은 세지 않는다 */
|
||||
let returnFocusTo: HTMLElement | null = null
|
||||
|
||||
/** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */
|
||||
function onFocusOut(event: FocusEvent, id: number) {
|
||||
const next = event.relatedTarget
|
||||
@@ -25,18 +39,70 @@ function onFocusOut(event: FocusEvent, id: number) {
|
||||
toastStore.release(id, 'focus')
|
||||
}
|
||||
|
||||
/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 */
|
||||
/** 바깥에서 알림으로 초점이 들어왔다 — 온 자리를 기억한다 (Tab · F6 · 마우스로 누름 — 온 자리가 없으면 null) */
|
||||
function onHostFocusIn(event: FocusEvent) {
|
||||
const from = event.relatedTarget
|
||||
if (from instanceof Node && hostRef.value?.contains(from)) return
|
||||
returnFocusTo = from instanceof HTMLElement ? from : null
|
||||
}
|
||||
|
||||
/** 화면의 main 에 초점을 둔다 — 초점을 받을 수 없으면 tabindex="-1" 을 잠깐 단다 */
|
||||
function focusMainRegion() {
|
||||
const main = document.querySelector<HTMLElement>('main')
|
||||
if (!main) return
|
||||
if (!main.hasAttribute('tabindex')) {
|
||||
main.setAttribute('tabindex', '-1')
|
||||
main.setAttribute(MAIN_FALLBACK_ATTR, '')
|
||||
main.addEventListener(
|
||||
'blur',
|
||||
() => {
|
||||
main.removeAttribute('tabindex')
|
||||
main.removeAttribute(MAIN_FALLBACK_ATTR)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}
|
||||
main.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
/** 알림에 들어오기 전 자리로 초점을 돌려준다 — 그 요소가 사라졌거나 초점을 받지 못하면 main */
|
||||
function restoreFocus() {
|
||||
const target = returnFocusTo
|
||||
returnFocusTo = null
|
||||
if (target?.isConnected && !hostRef.value?.contains(target)) {
|
||||
target.focus({ preventScroll: true })
|
||||
if (document.activeElement === target) return
|
||||
}
|
||||
focusMainRegion()
|
||||
}
|
||||
|
||||
/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 (이미 닫힌 알림이면 아무것도 하지 않는다) */
|
||||
function runAction(toast: Toast) {
|
||||
const action = toast.action
|
||||
toastStore.dismiss(toast.id)
|
||||
const action = toastStore.takeAction(toast.id)
|
||||
if (action && 'run' in action) action.run()
|
||||
}
|
||||
|
||||
// 알림이 닫힐 때 — 초점이 그 안에 있었으면 들어오기 전 자리로 (닫는 바로 그때 — 퇴장 전환 동안 남은 단추에 초점이 머물지 않게)
|
||||
watch(
|
||||
() => toastStore.toasts.map((toast) => toast.id),
|
||||
(ids) => {
|
||||
const host = hostRef.value
|
||||
const active = document.activeElement
|
||||
if (!host || !(active instanceof Element) || !host.contains(active)) return
|
||||
const focusedId = Number(active.closest('[data-toast-id]')?.getAttribute('data-toast-id'))
|
||||
if (ids.includes(focusedId)) return
|
||||
restoreFocus()
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="hostRef"
|
||||
class="toast-host"
|
||||
aria-label="알림"
|
||||
@focusin="onHostFocusIn"
|
||||
>
|
||||
<TransitionGroup
|
||||
name="toast"
|
||||
@@ -46,7 +112,8 @@ function runAction(toast: Toast) {
|
||||
<div
|
||||
v-for="toast in toastStore.toasts"
|
||||
:key="toast.id"
|
||||
:class="['toast', `toast--${toast.kind}`]"
|
||||
:class="['toast', `toast--${toast.kind}`, { 'toast--timed': toast.expiresAt !== null }]"
|
||||
:data-toast-id="toast.id"
|
||||
:role="toast.kind === 'error' ? 'alert' : 'status'"
|
||||
@mouseenter="toastStore.hold(toast.id, 'pointer')"
|
||||
@mouseleave="toastStore.release(toast.id, 'pointer')"
|
||||
@@ -95,6 +162,11 @@ function runAction(toast: Toast) {
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
<ToastCountdown
|
||||
v-if="toast.expiresAt !== null"
|
||||
:starts-at="toast.shownAt"
|
||||
:ends-at="toast.expiresAt"
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
@@ -123,6 +195,25 @@ function runAction(toast: Toast) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 마감 시각이 있는 알림 — 남은 시간 막대가 둘째 줄을 다 쓴다 (디자인 원본 MailSendLater: 위 8 · 아래 10) */
|
||||
.toast--timed {
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--space-4);
|
||||
--toast-countdown-track: var(--color-skeleton);
|
||||
--toast-countdown-bar: var(--color-brand);
|
||||
}
|
||||
|
||||
.toast--timed.toast--success {
|
||||
padding-bottom: var(--space-10);
|
||||
--toast-countdown-track: var(--color-on-brand-track);
|
||||
--toast-countdown-bar: var(--color-on-brand-progress);
|
||||
}
|
||||
|
||||
/* 줄을 나눌 때 글의 길이로 첫 줄을 넘기지 않게 — 아이콘 · 글 · 단추는 늘 첫 줄, 막대만 둘째 줄 */
|
||||
.toast--timed .toast__body {
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
padding: var(--space-12) var(--space-6) var(--space-12) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
@@ -251,6 +342,13 @@ function runAction(toast: Toast) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
/* 인쇄하지 않는다 — 고정 위치라 남아 있으면 쪽마다 찍힌다 (9단계 인쇄 화면) */
|
||||
@media print {
|
||||
.toast-host {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
import ComposeFooter from './ComposeFooter.vue'
|
||||
|
||||
// 쓰기 화면의 [보내기 ▾] — 메뉴는 보이는 영역 안에 다 들게 연다 (2026-10-29 리뷰 #10: 화면 맨 아래 단추 줄 밑으로 열려 화면 밖에 생기고,
|
||||
// 초점이 들어갈 때 쓰기 화면이 47px 튀어 올랐다). jsdom 은 배치를 하지 않으므로 상자 크기를 정해 준다
|
||||
|
||||
const PROPS = {
|
||||
canSend: true,
|
||||
sending: false,
|
||||
canSaveDraft: true,
|
||||
draftStatus: 'idle' as const,
|
||||
draftSavedAt: null,
|
||||
sender: '김아라 <ara.kim@arasystem.test>',
|
||||
}
|
||||
|
||||
function rectOf(top: number, bottom: number): DOMRect {
|
||||
return { top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
|
||||
}
|
||||
|
||||
describe('ComposeFooter — [보내기 ▾] 메뉴 자리', () => {
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
/** ▾ 단추의 위쪽 — 메뉴(54)는 단추 4 아래에 그려진다 */
|
||||
function layout(buttonTop: number) {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
if (this.matches('.compose__send-more')) return rectOf(buttonTop, buttonTop + 44)
|
||||
if (this.matches('[role="menu"]')) return rectOf(buttonTop + 48, buttonTop + 48 + 54)
|
||||
return rectOf(0, 0)
|
||||
})
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
wrapper = mount(ComposeFooter, { props: PROPS, attachTo: document.body })
|
||||
await wrapper.get('.compose__send-more').trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.get('[role="menu"]')
|
||||
}
|
||||
|
||||
it('단추 줄이 화면 맨 아래에 붙어 아래가 모자라면 위로 연다 — 초점은 화면 안의 메뉴로', async () => {
|
||||
layout(window.innerHeight - 50)
|
||||
|
||||
const menu = await openMenu()
|
||||
|
||||
expect(menu.classes()).toContain('compose__send-menu--up')
|
||||
// 키보드로 연 것과 같다(누름의 detail 0) — 초점은 메뉴 안의 첫 항목
|
||||
expect(menu.element.contains(document.activeElement)).toBe(true)
|
||||
expect(document.activeElement?.textContent).toContain('예약 발송…')
|
||||
})
|
||||
|
||||
it('아래가 넉넉하면 지금처럼 아래로 연다', async () => {
|
||||
layout(100)
|
||||
|
||||
const menu = await openMenu()
|
||||
|
||||
expect(menu.classes()).not.toContain('compose__send-menu--up')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// 쓰기 화면의 아래 단추 줄 — [보내기] [임시 저장] + 임시 저장 상태 글 + 보내는 사람 (디자인 원본 Compose · ComposeAssist)
|
||||
// 쓰기 화면의 아래 단추 줄 — [보내기 ▾] [임시 저장] + 임시 저장 상태 글 + 보내는 사람 (디자인 원본 Compose · ComposeAssist · MailSendLater)
|
||||
// 「취소」는 없다 — 머리의 닫기(✕)가 저장한 채 닫는다. [보내기]는 쓰기 화면의 form 을 제출한다(type="submit").
|
||||
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · 임시 저장 #compose-save-draft ·
|
||||
// [보내기 ▾](9단계): 단추를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계).
|
||||
// 메뉴는 편집기 메뉴(EditorMenu)를 쓴다 — 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점, Esc 는 ▾ 로 돌아간다.
|
||||
// Ctrl+Enter 는 그대로 [보내기]다. 두 단추는 같은 규칙으로 함께 꺼진다(보내는 중 · 첨부를 올리는 중 등 — canSend)
|
||||
// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 편집기가 늘어나 화면 맨 아래에 붙으므로
|
||||
// 보통은 위로 열린다 (아래로 열면 화면 밖에 생기고, 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
|
||||
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · ▾ .compose__send-more · 임시 저장 #compose-save-draft ·
|
||||
// 상태 #compose-draft-status(ComposeDraftStatus) · 줄 .compose__footer
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ComposeDraftStatus from '@/components/mail/ComposeDraftStatus.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import type { DraftSaveStatus } from '@/composables/useDraftSaving'
|
||||
import { usePopupPlacement } from '@/composables/usePopupPlacement'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
|
||||
interface Props {
|
||||
canSend: boolean
|
||||
@@ -21,27 +32,122 @@ interface Props {
|
||||
interface Emits {
|
||||
/** 「임시 저장」 · 상태 글의 [다시 시도] */
|
||||
(e: 'save-draft'): void
|
||||
/** ▾ › 「예약 발송…」 — 쓰기 화면이 예약 창을 연다 */
|
||||
(e: 'schedule'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const moreRef = ref<HTMLButtonElement | null>(null)
|
||||
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
|
||||
const menuOpen = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
/** 위로 연다 — 단추 아래가 모자랄 때 (메뉴를 그린 직후 · 초점을 주기 전에 잰다) */
|
||||
const { upward } = usePopupPlacement({
|
||||
open: menuOpen,
|
||||
trigger: () => moreRef.value,
|
||||
popup: () => {
|
||||
const menu: unknown = menuRef.value?.$el
|
||||
return menu instanceof HTMLElement ? menu : null
|
||||
},
|
||||
})
|
||||
|
||||
const items: readonly EditorMenuItem[] = [{ id: 'schedule', label: SEND_LATER_TEXT.scheduleItem, icon: 'clock' }]
|
||||
const trigger = computed<HTMLElement | null>(() => moreRef.value)
|
||||
|
||||
function openMenu(keyboard: boolean) {
|
||||
if (!props.canSend) return
|
||||
byKeyboard.value = keyboard
|
||||
menuOpen.value = true
|
||||
}
|
||||
|
||||
function onMoreClick(event: MouseEvent) {
|
||||
if (menuOpen.value) {
|
||||
menuOpen.value = false
|
||||
return
|
||||
}
|
||||
// detail 0 — Enter · Space 로 눌렀다
|
||||
openMenu(event.detail === 0)
|
||||
}
|
||||
|
||||
function onMoreKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' || menuOpen.value) return
|
||||
event.preventDefault()
|
||||
openMenu(true)
|
||||
}
|
||||
|
||||
function onMenuClose(restoreFocus: boolean) {
|
||||
menuOpen.value = false
|
||||
if (restoreFocus) moreRef.value?.focus()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
menuOpen.value = false
|
||||
moreRef.value?.focus()
|
||||
if (id === 'schedule' && props.canSend) emit('schedule')
|
||||
}
|
||||
|
||||
// 보내는 중 등으로 꺼지면 메뉴도 닫는다
|
||||
watch(
|
||||
() => props.canSend,
|
||||
(can) => {
|
||||
if (!can) menuOpen.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compose__footer">
|
||||
<button
|
||||
type="submit"
|
||||
class="compose__send"
|
||||
:disabled="!canSend"
|
||||
:aria-busy="sending"
|
||||
>
|
||||
<AppIcon
|
||||
name="send"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
<div class="compose__send-group">
|
||||
<button
|
||||
type="submit"
|
||||
class="compose__send"
|
||||
:disabled="!canSend"
|
||||
:aria-busy="sending"
|
||||
>
|
||||
<AppIcon
|
||||
name="send"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ sending ? '보내는 중…' : '보내기' }}</span>
|
||||
</button>
|
||||
<span
|
||||
class="compose__send-divider"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ sending ? '보내는 중…' : '보내기' }}</span>
|
||||
</button>
|
||||
<button
|
||||
ref="moreRef"
|
||||
type="button"
|
||||
class="compose__send-more"
|
||||
:aria-label="SEND_LATER_TEXT.menuButton"
|
||||
:title="SEND_LATER_TEXT.menuButton"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="menuOpen"
|
||||
:disabled="!canSend"
|
||||
@click="onMoreClick"
|
||||
@keydown="onMoreKeydown"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevron-down"
|
||||
:size="16"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
<EditorMenu
|
||||
v-if="menuOpen"
|
||||
ref="menuRef"
|
||||
:class="['compose__send-menu', { 'compose__send-menu--up': upward }]"
|
||||
:label="SEND_LATER_TEXT.menuLabel"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onMenuClose"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="compose-save-draft"
|
||||
type="button"
|
||||
@@ -77,29 +183,78 @@ const emit = defineEmits<Emits>()
|
||||
border-top: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
/* [보내기 ▾] — 둘을 한 덩어리로 (디자인 원본 MailSendLater: 모서리 8 · 사이에 옅은 흰 선). 메뉴는 이 덩어리 아래(모자라면 위)에서 연다 */
|
||||
.compose__send-group {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.compose__send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-20);
|
||||
padding: 0 var(--space-16) 0 var(--space-18);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compose__send:hover:not(:disabled) {
|
||||
/* 두 단추 사이 선 — 단추와 같은 바탕 위에 옅은 흰 선 (흰 쓰기 화면 위에서도 보이게). 단추가 꺼지면 함께 흐리게 */
|
||||
.compose__send-divider {
|
||||
width: var(--border-width);
|
||||
background: var(--color-brand);
|
||||
box-shadow: inset 0 0 0 var(--border-width) var(--color-on-brand-divider);
|
||||
}
|
||||
|
||||
.compose__send:disabled + .compose__send-divider {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ▾ — 44×44 (보드의 40 은 새 밀도 예외가 되므로 44 — README "9단계") */
|
||||
.compose__send-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.compose__send-more[aria-expanded='true'] {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.compose__send:disabled {
|
||||
.compose__send:hover:not(:disabled),
|
||||
.compose__send-more:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.compose__send:disabled,
|
||||
.compose__send-more:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 메뉴 폭 260 (디자인 원본 MailSendLater) — 단추 덩어리 왼쪽에 맞춘다 */
|
||||
.compose__send-menu {
|
||||
min-width: 16.25rem;
|
||||
}
|
||||
|
||||
/* 아래가 모자라면 위로 — 단추 위쪽에 같은 간격을 두고 연다 (편집기 메뉴의 아래 열림보다 앞선다) */
|
||||
.compose__send-group .compose__send-menu--up {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--space-4));
|
||||
}
|
||||
|
||||
/* 「임시 저장」 — 보조 단추 (디자인 원본 ComposeAssist) */
|
||||
.compose__save {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { formatScheduleTime } from '@/shared/utils/format'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import ComposeScheduleDialog from './ComposeScheduleDialog.vue'
|
||||
|
||||
// 예약 발송 창 (디자인 원본 MailSendLater · README "9단계 › 예약 창") — 시계는 주입한다(useClock), 시각은 브라우저 시간대(지역 시각)
|
||||
const local = (month: number, day: number, hour: number, minute = 0) => new Date(2026, month - 1, day, hour, minute).getTime()
|
||||
|
||||
describe('ComposeScheduleDialog', () => {
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mountAt(now: number): { wrapper: VueWrapper; clock: ManualClock } {
|
||||
const clock = createManualClock(now)
|
||||
const wrapper = mount(ComposeScheduleDialog, {
|
||||
props: { open: true },
|
||||
attachTo: document.body,
|
||||
global: { provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
return { wrapper, clock }
|
||||
}
|
||||
|
||||
function rows(wrapper: VueWrapper) {
|
||||
return wrapper.findAll('[data-schedule-preset]').map((input) => {
|
||||
const row = input.element.closest('.schedule__choice') as HTMLElement
|
||||
return {
|
||||
id: input.attributes('data-schedule-preset'),
|
||||
label: row.querySelector('.schedule__label')?.textContent,
|
||||
time: row.querySelector('.schedule__time')?.textContent ?? null,
|
||||
checked: (input.element as HTMLInputElement).checked,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('목요일 오후 3시 — 오늘 오후 · 내일 오전 · 내일 오후 · 다음 주 월요일 · 직접 고르기, 첫 줄을 고른 채 연다', () => {
|
||||
const { wrapper } = mountAt(local(10, 29, 15))
|
||||
|
||||
expect(wrapper.get('#compose-schedule-title').text()).toBe('예약 발송')
|
||||
expect(rows(wrapper)).toEqual([
|
||||
{ id: 'todayEvening', label: '오늘 오후', time: formatScheduleTime(local(10, 29, 18)), checked: true },
|
||||
{ id: 'tomorrowMorning', label: '내일 오전', time: formatScheduleTime(local(10, 30, 9)), checked: false },
|
||||
{ id: 'tomorrowAfternoon', label: '내일 오후', time: formatScheduleTime(local(10, 30, 13)), checked: false },
|
||||
{ id: 'nextMonday', label: '다음 주 월요일', time: formatScheduleTime(local(11, 2, 9)), checked: false },
|
||||
{ id: 'custom', label: '직접 고르기', time: null, checked: false },
|
||||
])
|
||||
// 직접 고르기의 처음 값은 내일 오전 9:00 · 날짜는 오늘부터 7일 뒤까지
|
||||
const day = wrapper.get('[data-schedule-day]')
|
||||
expect((day.element as HTMLInputElement).value).toBe('2026-10-30')
|
||||
expect(day.attributes()).toMatchObject({ min: '2026-10-29', max: '2026-11-05' })
|
||||
expect((wrapper.get('[data-schedule-time]').element as HTMLSelectElement).value).toBe(String(9 * 60))
|
||||
expect(wrapper.findAll('[data-schedule-time] option')).toHaveLength(48)
|
||||
expect(wrapper.get('#compose-schedule-note').text()).toMatch(/^이 PC 의 시간(\(.+\))?으로 보냅니다 · 7일 안에서 고를 수 있습니다\.$/)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-schedule-confirm]').element)
|
||||
})
|
||||
|
||||
it('[예약] — 고른 시각(ms)을 넘긴다', async () => {
|
||||
const { wrapper } = mountAt(local(10, 29, 15))
|
||||
|
||||
await wrapper.get('[data-schedule-preset="tomorrowAfternoon"]').setValue(true)
|
||||
await wrapper.get('[data-schedule-confirm]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('confirm')).toEqual([[local(10, 30, 13)]])
|
||||
})
|
||||
|
||||
it('직접 고르기 — 날짜 · 시각을 만지면 그 줄을 고르고, 그 시각을 넘긴다', async () => {
|
||||
const { wrapper } = mountAt(local(10, 29, 15))
|
||||
|
||||
await wrapper.get('[data-schedule-day]').setValue('2026-10-31')
|
||||
await wrapper.get('[data-schedule-time]').setValue(String(14 * 60 + 30))
|
||||
expect((wrapper.get('[data-schedule-preset="custom"]').element as HTMLInputElement).checked).toBe(true)
|
||||
await wrapper.get('[data-schedule-confirm]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('confirm')).toEqual([[local(10, 31, 14, 30)]])
|
||||
})
|
||||
|
||||
it('지금부터 5분 뒤 이전이면 "지금부터 5분 뒤 이후의 시각을 고르세요." — 칸이 그 글을 가리키고 넘기지 않는다', async () => {
|
||||
const { wrapper } = mountAt(local(10, 29, 15))
|
||||
|
||||
await wrapper.get('[data-schedule-day]').setValue('2026-10-29')
|
||||
await wrapper.get('[data-schedule-time]').setValue(String(15 * 60))
|
||||
await wrapper.get('[data-schedule-confirm]').trigger('click')
|
||||
|
||||
const error = wrapper.get('#compose-schedule-error')
|
||||
expect(error.text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
|
||||
expect(error.attributes('role')).toBe('alert')
|
||||
expect(wrapper.get('[data-schedule-day]').attributes()).toMatchObject({
|
||||
'aria-invalid': 'true',
|
||||
'aria-describedby': 'compose-schedule-error',
|
||||
})
|
||||
expect(wrapper.emitted('confirm')).toBeUndefined()
|
||||
|
||||
// 다른 줄을 고르면 내린다
|
||||
await wrapper.get('[data-schedule-preset="tomorrowMorning"]').setValue(true)
|
||||
expect(wrapper.get('#compose-schedule-error').text()).toBe('')
|
||||
})
|
||||
|
||||
it('7일을 넘으면 "7일 안의 시각만 고를 수 있습니다."', async () => {
|
||||
const { wrapper } = mountAt(local(10, 29, 15))
|
||||
|
||||
await wrapper.get('[data-schedule-day]').setValue('2026-11-05')
|
||||
await wrapper.get('[data-schedule-time]').setValue(String(15 * 60 + 30))
|
||||
await wrapper.get('[data-schedule-confirm]').trigger('click')
|
||||
|
||||
expect(wrapper.get('#compose-schedule-error').text()).toBe('7일 안의 시각만 고를 수 있습니다.')
|
||||
expect(wrapper.emitted('confirm')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('창을 연 뒤 시간이 흘러 고른 시각이 5분 안이 되면 [예약]을 누른 때로 다시 본다', async () => {
|
||||
const { wrapper, clock } = mountAt(local(10, 29, 17, 50))
|
||||
expect(rows(wrapper)[0]).toMatchObject({ id: 'todayEvening', checked: true })
|
||||
|
||||
clock.advance(6 * 60_000)
|
||||
await wrapper.get('[data-schedule-confirm]').trigger('click')
|
||||
|
||||
expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
|
||||
expect(wrapper.emitted('confirm')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('다시 열면 그때의 시각으로 고르기를 새로 만든다 — 오늘 오후가 지났으면 내일 오전이 첫 줄', async () => {
|
||||
const { wrapper, clock } = mountAt(local(10, 29, 15))
|
||||
await wrapper.setProps({ open: false })
|
||||
|
||||
clock.advance(4 * 60 * 60_000)
|
||||
await wrapper.setProps({ open: true })
|
||||
|
||||
expect(rows(wrapper).map((row) => row.id)).toEqual(['tomorrowMorning', 'tomorrowAfternoon', 'nextMonday', 'custom'])
|
||||
expect(rows(wrapper)[0]?.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('[취소] · Esc 는 cancel', async () => {
|
||||
const { wrapper } = mountAt(local(10, 29, 15))
|
||||
|
||||
await wrapper.get('[data-schedule-cancel]').trigger('click')
|
||||
wrapper.get('dialog').element.dispatchEvent(new Event('cancel', { cancelable: true }))
|
||||
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,336 @@
|
||||
<script setup lang="ts">
|
||||
// 예약 발송 창 (디자인 원본 MailSendLater · README "9단계 › 예약 창") — 모달 400(medium), 쓰기 화면의 ▾ › 「예약 발송…」이 연다
|
||||
// 고르기: 「오늘 오후」 6:00(남았을 때만) · 「내일 오전」 9:00 · 「내일 오후」 1:00 · 「다음 주 월요일」 9:00 · 「직접 고르기」(날짜 칸 + 30분 단위 시각) —
|
||||
// 줄마다 오른쪽에 날짜 · 요일 · 시각(브라우저 시간대). 예약할 수 없는 시각(5분 안 · 7일 넘음)인 고르기는 두지 않는다 (schedule-send.ts).
|
||||
// 처음 고른 것은 첫 줄. 안내 "이 PC 의 시간(…)으로 보냅니다 · 7일 안에서 고를 수 있습니다." [예약] [취소]
|
||||
// [예약]을 누르면 그 순간의 시각으로 다시 본다 — 지금부터 5분 뒤 이전이면 "지금부터 5분 뒤 이후의 시각을 고르세요.", 7일을 넘으면
|
||||
// "7일 안의 시각만 고를 수 있습니다." (고르기 아래 — 직접 고르기의 칸이 그 글을 가리킨다). 맞으면 보낼 시각(ms)을 confirm 으로 넘긴다.
|
||||
// 시각은 주입한 시계(useClock)로 센다 — 테스트가 손으로 돌리는 시계로 프리셋 · 검사를 맞춘다. 열 때마다 처음 값으로 시작한다.
|
||||
// 서버가 고른 시각을 받지 않아(400 VAL_001) 쓰기 화면이 창을 다시 열면(rejection) 그 시각을 「직접 고르기」 칸에 채우고, 창이 열린 뒤 고르기 아래에 까닭을 보인다
|
||||
// (role="alert" 가 바뀜으로 읽히게 — 처음부터 있던 글은 읽지 않는 스크린리더가 있다)
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { SEND_LATER_TEXT, type SchedulePresetId } from '@/shared/constants/mail-send-later'
|
||||
import { formatScheduleTime } from '@/shared/utils/format'
|
||||
import {
|
||||
browserTimeZoneName,
|
||||
customScheduleDayRange,
|
||||
customScheduleOf,
|
||||
customScheduleTime,
|
||||
defaultCustomSchedule,
|
||||
formatTimeSlot,
|
||||
schedulePresets,
|
||||
scheduleProblem,
|
||||
scheduleTimeSlots,
|
||||
type ScheduleProblem,
|
||||
type SchedulePreset,
|
||||
type ScheduleRejection,
|
||||
} from '@/shared/utils/schedule-send'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 서버가 받지 않은 시각으로 다시 열었다 — 그 시각 · 까닭을 보인다 */
|
||||
rejection?: ScheduleRejection | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 보낼 시각(ms) — 검사를 통과했다 */
|
||||
(e: 'confirm', at: number): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rejection: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const clock = useClock()
|
||||
|
||||
const ERROR_ID = 'compose-schedule-error'
|
||||
const NOTE_ID = 'compose-schedule-note'
|
||||
const CUSTOM = 'custom'
|
||||
type ScheduleChoice = SchedulePresetId | typeof CUSTOM
|
||||
|
||||
const openedAt = ref<number>(clock.now())
|
||||
const choice = ref<ScheduleChoice>(CUSTOM)
|
||||
const customDay = ref<string>('')
|
||||
const customMinutes = ref<number>(0)
|
||||
const problem = ref<ScheduleProblem | null>(null)
|
||||
|
||||
const presets = computed<SchedulePreset[]>(() => schedulePresets(openedAt.value))
|
||||
const dayRange = computed(() => customScheduleDayRange(openedAt.value))
|
||||
const slots = scheduleTimeSlots()
|
||||
const zoneName = computed(() => browserTimeZoneName(openedAt.value))
|
||||
const note = computed(() => (zoneName.value ? SEND_LATER_TEXT.note(zoneName.value) : SEND_LATER_TEXT.noteWithoutZone))
|
||||
const problemText = computed(() => (problem.value ? SEND_LATER_TEXT[problem.value] : ''))
|
||||
|
||||
/** 열 때마다 — 지금 시각으로 고르기를 다시 만들고 첫 줄을 고른다 */
|
||||
function reset() {
|
||||
openedAt.value = clock.now()
|
||||
const first = presets.value[0]
|
||||
choice.value = first ? first.id : CUSTOM
|
||||
const custom = defaultCustomSchedule(openedAt.value)
|
||||
customDay.value = custom.day
|
||||
customMinutes.value = custom.minutes
|
||||
problem.value = null
|
||||
const rejected = props.rejection
|
||||
if (!rejected) return
|
||||
// 서버가 받지 않은 시각 — 「직접 고르기」에 그 시각을 채우고(칸이 까닭을 가리킨다), 창이 열린 뒤 까닭을 보인다
|
||||
const chosen = customScheduleOf(rejected.at)
|
||||
if (chosen) {
|
||||
choice.value = CUSTOM
|
||||
customDay.value = chosen.day
|
||||
customMinutes.value = chosen.minutes
|
||||
}
|
||||
void nextTick(() => {
|
||||
if (props.open && props.rejection === rejected) problem.value = rejected.problem
|
||||
})
|
||||
}
|
||||
|
||||
/** 고른 시각 — 직접 고르기의 날짜가 비었거나 틀리면 NaN (검사가 "5분 뒤 이후"로 막는다) */
|
||||
function chosenTime(): number {
|
||||
if (choice.value === CUSTOM) {
|
||||
return customScheduleTime({ day: customDay.value, minutes: customMinutes.value }) ?? Number.NaN
|
||||
}
|
||||
return presets.value.find((preset) => preset.id === choice.value)?.at ?? Number.NaN
|
||||
}
|
||||
|
||||
/** 직접 고르기의 칸을 만지면 그 줄을 고른다 */
|
||||
function chooseCustom() {
|
||||
choice.value = CUSTOM
|
||||
problem.value = null
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
const at = chosenTime()
|
||||
problem.value = scheduleProblem(at, clock.now())
|
||||
if (problem.value === null) emit('confirm', at)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) reset()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 고르기를 바꾸면 오류를 내린다
|
||||
watch(choice, () => {
|
||||
problem.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
:title="SEND_LATER_TEXT.dialogTitle"
|
||||
title-id="compose-schedule-title"
|
||||
:described-by="NOTE_ID"
|
||||
medium
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<div
|
||||
class="schedule"
|
||||
data-schedule-dialog
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
class="schedule__choices"
|
||||
:aria-label="SEND_LATER_TEXT.presetGroup"
|
||||
>
|
||||
<label
|
||||
v-for="preset in presets"
|
||||
:key="preset.id"
|
||||
:class="['schedule__choice', { 'is-checked': choice === preset.id }]"
|
||||
>
|
||||
<input
|
||||
v-model="choice"
|
||||
type="radio"
|
||||
name="compose-schedule"
|
||||
:value="preset.id"
|
||||
class="schedule__radio"
|
||||
:data-schedule-preset="preset.id"
|
||||
>
|
||||
<span class="schedule__label">{{ SEND_LATER_TEXT.presets[preset.id] }}</span>
|
||||
<span class="schedule__time">{{ formatScheduleTime(preset.at) }}</span>
|
||||
</label>
|
||||
<div :class="['schedule__choice', 'schedule__choice--custom', { 'is-checked': choice === CUSTOM }]">
|
||||
<label class="schedule__custom-label">
|
||||
<input
|
||||
v-model="choice"
|
||||
type="radio"
|
||||
name="compose-schedule"
|
||||
:value="CUSTOM"
|
||||
class="schedule__radio"
|
||||
data-schedule-preset="custom"
|
||||
>
|
||||
<span class="schedule__label">{{ SEND_LATER_TEXT.custom }}</span>
|
||||
</label>
|
||||
<div class="schedule__custom-fields">
|
||||
<input
|
||||
v-model="customDay"
|
||||
type="date"
|
||||
class="form-input schedule__date"
|
||||
:min="dayRange.min"
|
||||
:max="dayRange.max"
|
||||
:aria-label="SEND_LATER_TEXT.customDate"
|
||||
:aria-invalid="choice === CUSTOM && problem !== null ? 'true' : undefined"
|
||||
:aria-describedby="choice === CUSTOM && problem ? ERROR_ID : undefined"
|
||||
data-schedule-day
|
||||
@focus="chooseCustom"
|
||||
@input="chooseCustom"
|
||||
>
|
||||
<select
|
||||
v-model.number="customMinutes"
|
||||
class="form-input schedule__select"
|
||||
:aria-label="SEND_LATER_TEXT.customTime"
|
||||
:aria-invalid="choice === CUSTOM && problem !== null ? 'true' : undefined"
|
||||
:aria-describedby="choice === CUSTOM && problem ? ERROR_ID : undefined"
|
||||
data-schedule-time
|
||||
@focus="chooseCustom"
|
||||
@change="chooseCustom"
|
||||
>
|
||||
<option
|
||||
v-for="slot in slots"
|
||||
:key="slot"
|
||||
:value="slot"
|
||||
>
|
||||
{{ formatTimeSlot(slot) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
:id="ERROR_ID"
|
||||
class="form-message form-message--error schedule__error"
|
||||
role="alert"
|
||||
>
|
||||
<template v-if="problem">
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
/>
|
||||
<span>{{ problemText }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<p
|
||||
:id="NOTE_ID"
|
||||
class="form-message"
|
||||
>
|
||||
{{ note }}
|
||||
</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
autofocus
|
||||
data-schedule-confirm
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.confirm }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
data-schedule-cancel
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.schedule {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.schedule__choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* 고르기 줄 — 44 · 테두리 상자, 고른 줄은 청록 테두리 + 옅은 바탕 (디자인 원본 MailSendLater) */
|
||||
.schedule__choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule__choice.is-checked {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.schedule__choice--custom {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-10);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.schedule__custom-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule__radio {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.schedule__label {
|
||||
flex: 1 1 auto;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.schedule__time {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 날짜 · 시각 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
|
||||
.schedule__custom-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-8);
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.schedule__date {
|
||||
flex: 1 1 9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule__select {
|
||||
flex: 0 0 8.125rem;
|
||||
}
|
||||
|
||||
/* 오류가 없으면 자리를 차지하지 않는다 (글이 생기면 role="alert" 로 읽힌다) */
|
||||
.schedule__error:empty {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import { MAIL_FRAME_CSP } from '@/shared/utils/mail-html'
|
||||
import { MAIL_FRAME_CSP, mailFrameCspImagesShown } from '@/shared/utils/mail-html'
|
||||
import MailBodyFrame from './MailBodyFrame.vue'
|
||||
|
||||
// 보안 핵심 — 본문 iframe 의 격리 조건이 바뀌면 이 테스트가 깨져야 한다.
|
||||
@@ -52,4 +52,44 @@ describe('MailBodyFrame', () => {
|
||||
expect(srcdoc).toContain('<b>굵게</b> 아님')
|
||||
expect(srcdoc).not.toContain('<b>')
|
||||
})
|
||||
|
||||
describe('외부 이미지를 보일 때 (9단계 — imagesShown)', () => {
|
||||
const PROXIED = '/api/mail-images/tok3n_value'
|
||||
|
||||
it('내부 CSP 의 그림만 이 화면 출처의 중계 경로(/api/mail-images/)를 더 열고 중계 주소의 그림을 남긴다 — sandbox 는 그대로', () => {
|
||||
const wrapper = mount(MailBodyFrame, { props: { htmlBody: `<p>본문</p><img src="${PROXIED}">`, imagesShown: true } })
|
||||
const frame = wrapper.get('iframe').element as HTMLIFrameElement
|
||||
const srcdoc = frame.getAttribute('srcdoc') ?? ''
|
||||
const tokens = (frame.getAttribute('sandbox') ?? '').split(/\s+/).filter(Boolean).sort()
|
||||
|
||||
expect(srcdoc).toContain(`content="${mailFrameCspImagesShown(window.location.origin)}"`)
|
||||
expect(srcdoc).toContain(`img-src data: ${window.location.origin}/api/mail-images/;`)
|
||||
expect(srcdoc).not.toContain("'self'")
|
||||
expect(srcdoc).toContain(`src="${PROXIED}"`)
|
||||
expect(tokens).toEqual(['allow-popups', 'allow-popups-to-escape-sandbox', 'allow-same-origin'])
|
||||
})
|
||||
|
||||
it('막아 둔 본문이면(기본) 지금 CSP 그대로 — 중계 주소의 그림도 지운다', () => {
|
||||
const wrapper = mount(MailBodyFrame, { props: { htmlBody: `<p>본문</p><img src="${PROXIED}">` } })
|
||||
const srcdoc = wrapper.get('iframe').attributes('srcdoc') ?? ''
|
||||
|
||||
expect(srcdoc).toContain(`content="${MAIL_FRAME_CSP}"`)
|
||||
expect(srcdoc).not.toContain(PROXIED)
|
||||
})
|
||||
})
|
||||
|
||||
it('문서를 다 열면 높이를 재서 measured 로 알리고, 본문 안의 키는 frame-keydown 으로 알린다 (읽기 화면의 Ctrl+P · 인쇄 화면)', async () => {
|
||||
const wrapper = mount(MailBodyFrame, { props: { htmlBody: '<p>본문</p>' }, attachTo: document.body })
|
||||
const frame = wrapper.get('iframe').element as HTMLIFrameElement
|
||||
|
||||
frame.dispatchEvent(new Event('load'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('measured')?.length).toBeGreaterThan(0)
|
||||
expect(frame.getAttribute('height')).not.toBeNull()
|
||||
|
||||
frame.contentDocument?.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, bubbles: true }))
|
||||
const keys = (wrapper.emitted('frame-keydown') ?? []).map(([event]) => (event as KeyboardEvent).key)
|
||||
expect(keys).toContain('p')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
// - allow-popups · allow-popups-to-escape-sandbox: 링크를 새 창으로 열고, 새 창은 sandbox 없이 정상 동작하게 한다.
|
||||
// 링크는 정화 단계에서 http·https·mailto 만 남기고 rel="noopener noreferrer" 를 붙인다.
|
||||
// 문서 안에는 CSP(default-src 'none'; img-src data:; style-src 'unsafe-inline')와 <base target="_blank"> 가 들어간다.
|
||||
// 외부 이미지를 보이는 본문(imagesShown — 서버가 중계 주소로 바꿔 넣었다, 9단계)일 때만 CSP 의 그림에 우리 출처의 중계 경로
|
||||
// (<출처>/api/mail-images/ — 'self' 전체가 아니다)를 더하고 정화가 중계 주소의 그림을 남긴다 — sandbox 는 그대로다 (mail-html.ts).
|
||||
// 내용 높이를 잴 때마다 measured 를 알린다 — 인쇄 화면이 다 잰 뒤(그림까지 받은 뒤 — iframe 의 load) 인쇄 창을 띄운다
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { buildMailDocument } from '@/shared/utils/mail-html'
|
||||
@@ -19,15 +22,29 @@ interface Props {
|
||||
htmlBody?: string | null
|
||||
/** HTML 이 없을 때 이스케이프해서 보여 줄 텍스트 */
|
||||
textBody?: string | null
|
||||
/** 외부 이미지를 보이는 본문 — 서버의 MessageDetail.imagesShown */
|
||||
imagesShown?: boolean
|
||||
/** iframe 이름 (스크린리더) */
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 문서를 다 열고(그림까지 — load) 내용 높이를 쟀다 · 폭이 바뀌어 다시 쟀다 (px) */
|
||||
(e: 'measured', height: number): void
|
||||
/**
|
||||
* 본문 안에서 누른 키 — 본문을 눌러 초점이 iframe 안에 있으면 키가 부모 화면에 오지 않는다.
|
||||
* 읽기 화면의 Ctrl+P(인쇄 화면)가 이것도 본다. 처리기는 부모 쪽 코드라 sandbox(스크립트 불허)와 상관없이 돈다
|
||||
*/
|
||||
(e: 'frame-keydown', event: KeyboardEvent): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
htmlBody: null,
|
||||
textBody: null,
|
||||
imagesShown: false,
|
||||
title: '메일 본문',
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** sandbox 값 — 위 설명 외의 권한을 추가하지 않는다 */
|
||||
const SANDBOX = 'allow-same-origin allow-popups allow-popups-to-escape-sandbox'
|
||||
@@ -37,7 +54,7 @@ const frameRef = ref<HTMLIFrameElement | null>(null)
|
||||
const contentHeight = ref<number | null>(null)
|
||||
|
||||
const srcdoc = computed(() =>
|
||||
buildMailDocument({ htmlBody: props.htmlBody, textBody: props.textBody }),
|
||||
buildMailDocument({ htmlBody: props.htmlBody, textBody: props.textBody, imagesShown: props.imagesShown }),
|
||||
)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
@@ -56,6 +73,7 @@ function measure() {
|
||||
const viewportHeight = frame.contentWindow?.innerHeight ?? 0
|
||||
const scrollbar = Math.max(0, viewportHeight - doc.documentElement.clientHeight)
|
||||
contentHeight.value = Math.ceil(doc.body.scrollHeight) + scrollbar
|
||||
emit('measured', contentHeight.value)
|
||||
}
|
||||
|
||||
function scheduleMeasure() {
|
||||
@@ -63,8 +81,14 @@ function scheduleMeasure() {
|
||||
measureFrame = requestAnimationFrame(measure)
|
||||
}
|
||||
|
||||
function onFrameKeydown(event: KeyboardEvent) {
|
||||
emit('frame-keydown', event)
|
||||
}
|
||||
|
||||
function onLoad() {
|
||||
documentLoaded = true
|
||||
// 문서를 새로 열 때마다 단다 — 옛 문서와 함께 옛 처리기도 사라진다
|
||||
frameRef.value?.contentDocument?.addEventListener('keydown', onFrameKeydown)
|
||||
measure()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,21 +3,34 @@
|
||||
// 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크, 일부만 골랐으면(표 머리) – 표시.
|
||||
// 누르는 영역(폭 · 높이)은 쓰는 쪽이 class 로 정한다 — 표의 고르기 칸 32 · 분할 보기 행의 고르기 칸 36 (밀도 예외, tokens.css).
|
||||
// 수정 글쇠(Shift — 범위)는 쓰는 쪽이 판단하도록 click 이벤트를 그대로 넘긴다.
|
||||
// 고를 수 없는 줄(unavailable — 아직 보내지 않은 메일, 9단계)은 disabled 대신 aria-disabled — 초점은 받고(Tab 자리를 잃지 않게)
|
||||
// 누르거나 Space · Enter 를 눌러도 알리지 않는다. 까닭(note)은 title — 마우스를 올리면 보이고 스크린리더가 설명으로 읽는다
|
||||
interface Props {
|
||||
checked: boolean
|
||||
/** 일부만 골랐다 — 표 머리의 상자 (aria-checked="mixed") */
|
||||
mixed?: boolean
|
||||
label: string
|
||||
/** 고를 수 없다 */
|
||||
unavailable?: boolean
|
||||
/** 고를 수 없는 까닭 */
|
||||
note?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle', event: MouseEvent): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
unavailable: false,
|
||||
note: undefined,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (props.unavailable) return
|
||||
emit('toggle', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,7 +40,9 @@ const emit = defineEmits<Emits>()
|
||||
class="mail-checkbox"
|
||||
:aria-checked="mixed ? 'mixed' : checked"
|
||||
:aria-label="label"
|
||||
@click="emit('toggle', $event)"
|
||||
:aria-disabled="unavailable ? 'true' : undefined"
|
||||
:title="unavailable ? note : undefined"
|
||||
@click="onClick"
|
||||
>
|
||||
<svg
|
||||
class="mail-checkbox__box"
|
||||
@@ -102,4 +117,14 @@ const emit = defineEmits<Emits>()
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* 고를 수 없는 줄 — 옅은 칸 */
|
||||
.mail-checkbox[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mail-checkbox[aria-disabled='true'] .mail-checkbox__empty {
|
||||
fill: var(--color-surface-muted);
|
||||
stroke: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
|
||||
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
|
||||
// 그 아래 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
|
||||
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음
|
||||
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음 · 예약한 메일 없음(9단계 — 예약됨에는 알림 권하기 줄이 없다)
|
||||
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
|
||||
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
@@ -22,7 +22,9 @@ import { useListPositionMemory } from '@/composables/useListPositionMemory'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { isScheduledSource } from '@/shared/utils/mail-list-source'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -45,19 +47,24 @@ const { noticeCount, goTop } = useListArrivalNotice(sectionRef, topRef)
|
||||
useListPositionMemory(sectionRef, () => props.variant === 'list')
|
||||
|
||||
const search = computed(() => mailStore.search)
|
||||
/** 예약됨 (9단계) — 고르기 · 알림 권하기가 없다 */
|
||||
const scheduled = computed(() => isScheduledSource(mailStore.listSource))
|
||||
const selecting = computed(() => mailStore.checkedCount > 0)
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
scheduled.value
|
||||
? { title: SEND_LATER_TEXT.emptyTitle, hint: SEND_LATER_TEXT.emptyHint }
|
||||
: emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const emptyIcon = computed(() => (scheduled.value ? 'clock' : mailboxIcon({ role: mailbox.value?.role ?? null })))
|
||||
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
|
||||
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
|
||||
|
||||
function retry() {
|
||||
const current = search.value
|
||||
if (current) void mailStore.openSearch(current.q, current.scope, { force: true })
|
||||
else if (scheduled.value) void mailStore.openScheduled()
|
||||
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
|
||||
}
|
||||
|
||||
@@ -129,7 +136,7 @@ watch(
|
||||
/>
|
||||
<MailSearchScope v-if="search" />
|
||||
<MailNotifyOffer
|
||||
v-else
|
||||
v-else-if="!scheduled"
|
||||
:variant="variant"
|
||||
@leave="onOfferLeave"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
|
||||
// 예약됨(9단계)은 "예약됨" · "2통 · 보낼 시각 차례" (디자인 원본 MailSendLater)
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
|
||||
// 스크롤해도 위에 남는 것은 목록 틀(MailListFrame)의 머리 묶음이 맡는다 — 메일을 고르면 이 자리가 도구 줄로 바뀐다.
|
||||
@@ -8,7 +9,9 @@ import { computed } from 'vue'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxEmptyButton from '@/components/mail/MailboxEmptyButton.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { isScheduledSource } from '@/shared/utils/mail-list-source'
|
||||
import { isEmptiableMailbox } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -24,8 +27,13 @@ withDefaults(defineProps<Props>(), {
|
||||
|
||||
const mailStore = useMailStore()
|
||||
|
||||
const title = computed(() => mailStore.currentMailbox?.name ?? '메일함')
|
||||
const scheduled = computed(() => isScheduledSource(mailStore.listSource))
|
||||
const title = computed(() => (scheduled.value ? SEND_LATER_TEXT.mailbox : (mailStore.currentMailbox?.name ?? '메일함')))
|
||||
const countText = computed(() => {
|
||||
if (scheduled.value) {
|
||||
if (mailStore.listStatus !== 'ready') return ''
|
||||
return SEND_LATER_TEXT.listCount(formatCount(mailStore.pageInfo?.total ?? mailStore.messages.length))
|
||||
}
|
||||
const box = mailStore.currentMailbox
|
||||
if (!box) return ''
|
||||
const total = `전체 ${formatCount(box.totalCount)}`
|
||||
@@ -34,7 +42,7 @@ const countText = computed(() => {
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함에 메일이 있을 때 */
|
||||
const emptiable = computed(() => {
|
||||
const box = mailStore.currentMailbox
|
||||
const box = scheduled.value ? null : mailStore.currentMailbox
|
||||
return box && isEmptiableMailbox(box) && box.totalCount > 0 ? box : null
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
// - 메뉴는 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점.
|
||||
// Esc 는 메뉴만 닫고 이 단추로 돌아간다. 항목을 고르면 메뉴를 닫고 이 단추에 초점을 둔 뒤 알린다 —
|
||||
// 대화상자가 열리면 닫힐 때 초점이 이 단추로 돌아온다
|
||||
// - 메뉴는 아래로 열되, 메일함 패널(스크롤되는 칸)의 보이는 영역 아래가 모자라면 위로 연다 — 아래쪽 줄의 메뉴가 잘리지 않게 (popup-placement.ts)
|
||||
// - 메뉴는 아래로 열되, 메일함 패널(스크롤되는 칸)의 보이는 영역 아래가 모자라면 위로 연다 — 아래쪽 줄의 메뉴가 잘리지 않게 (usePopupPlacement)
|
||||
// - 주소록 패널의 그룹 줄 ⋯ 도 이 단추를 쓴다(8단계 — 같은 32×40 밀도 예외) — 단추 · 메뉴 이름은 label · menuLabel 로 바꾼다
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { usePopupPlacement } from '@/composables/usePopupPlacement'
|
||||
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { opensUpward } from '@/shared/utils/popup-placement'
|
||||
|
||||
interface Props {
|
||||
/** 메일함 이름 — 단추 · 메뉴의 이름 */
|
||||
@@ -36,16 +36,20 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
|
||||
const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip', 'overlay'])
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
/** 위로 연다 — 아래가 모자랄 때 */
|
||||
const upward = ref<boolean>(false)
|
||||
/** 위로 연다 — 아래가 모자랄 때 (메뉴를 그린 직후 · 초점을 주기 전에 잰다) */
|
||||
const { upward } = usePopupPlacement({
|
||||
open,
|
||||
trigger: () => buttonRef.value,
|
||||
popup: () => {
|
||||
const menu: unknown = menuRef.value?.$el
|
||||
return menu instanceof HTMLElement ? menu : null
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
const buttonLabel = computed(() => props.label ?? MAILBOX_PANEL_TEXT.more(props.mailboxName))
|
||||
@@ -57,37 +61,6 @@ function setOpen(next: boolean) {
|
||||
emit('open-change', next)
|
||||
}
|
||||
|
||||
/** 단추가 보일 수 있는 세로 영역 — 화면과 잘라 내는 조상(메일함 패널 등)들이 겹치는 부분 */
|
||||
function visibleArea(element: Element): { top: number; bottom: number } {
|
||||
let top = 0
|
||||
let bottom = window.innerHeight
|
||||
for (let node = element.parentElement; node; node = node.parentElement) {
|
||||
if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
|
||||
const rect = node.getBoundingClientRect()
|
||||
top = Math.max(top, rect.top)
|
||||
bottom = Math.min(bottom, rect.bottom)
|
||||
}
|
||||
return { top, bottom }
|
||||
}
|
||||
|
||||
/** 아래로 그린 메뉴를 재어 위로 열지 정한다 */
|
||||
function place() {
|
||||
const button = buttonRef.value
|
||||
const menu = menuRef.value?.$el
|
||||
if (!button || !(menu instanceof HTMLElement)) return
|
||||
const buttonRect = button.getBoundingClientRect()
|
||||
const menuRect = menu.getBoundingClientRect()
|
||||
const area = visibleArea(button)
|
||||
upward.value = opensUpward({
|
||||
triggerTop: buttonRect.top,
|
||||
triggerBottom: buttonRect.bottom,
|
||||
popupHeight: menuRect.height,
|
||||
areaTop: area.top,
|
||||
areaBottom: area.bottom,
|
||||
gap: Math.max(0, menuRect.top - buttonRect.bottom),
|
||||
})
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (open.value) {
|
||||
setOpen(false)
|
||||
@@ -115,16 +88,6 @@ function onSelect(id: string) {
|
||||
buttonRef.value?.focus()
|
||||
emit('select', id)
|
||||
}
|
||||
|
||||
// 메뉴를 그린 직후(초점을 주기 전) 잰다 — 닫으면 다음에 열 때 다시 아래로 재기 시작한다
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
upward.value = false
|
||||
if (isOpen) place()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// - 메일함 목록은 두 묶음(기본 메일함 · "내 메일함")과 [+ 새 메일함] — MailboxTree
|
||||
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
|
||||
// - 아래: 부재중 자동 응답이 켜져 있으면 안내(MailVacationNotice) → 사용량
|
||||
// - 「예약됨」(9단계)은 예약한 메일이 있을 때만 보낸편지함 아래 — 수는 메일 화면이 받아 둔다(mail.store 의 scheduledCount)
|
||||
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -15,6 +16,7 @@ import MailboxTree from '@/components/mail/MailboxTree.vue'
|
||||
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
|
||||
import { useMailboxActions } from '@/composables/useMailboxActions'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { isScheduledRouteName } from '@/shared/utils/mail-list-source'
|
||||
import type { MailboxAction } from '@/shared/utils/mailbox'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -30,6 +32,7 @@ const dialog = ref<MailboxDialogRequest | null>(null)
|
||||
|
||||
const isComposing = computed(() => route.name === 'mail-compose')
|
||||
const activeMailboxId = computed(() => (isComposing.value ? null : mailStore.currentMailboxId))
|
||||
const scheduledActive = computed(() => isScheduledRouteName(route.name))
|
||||
|
||||
/** ⋯ 메뉴에서 고른 일 — 모두 읽음은 바로, 그 밖은 대화상자를 거친다 */
|
||||
function onAction(action: MailboxAction, mailbox: Mailbox) {
|
||||
@@ -88,6 +91,8 @@ watch(
|
||||
v-if="mailStore.orderedMailboxes.length > 0"
|
||||
:nodes="mailStore.orderedMailboxes"
|
||||
:active-id="activeMailboxId"
|
||||
:scheduled-count="mailStore.scheduledCount"
|
||||
:scheduled-active="scheduledActive"
|
||||
@action="onAction"
|
||||
@create="dialog = { kind: 'create', parent: null }"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널의 「예약됨」 (디자인 원본 MailSendLater · README "9단계 › 「예약됨」") — 보낸편지함 아래, 예약한 메일이 있을 때만(부르는 쪽이 정한다 —
|
||||
// 예약됨을 보는 동안은 0통이어도 남는다)
|
||||
// 시계 아이콘 · 이름 · 오른쪽에 예약한 통 수(평범한 숫자, 뱃지 아님 — 0 이면 숫자를 두지 않는다). 메일함 줄(MailboxPanelRow)과 같은 모양이고 ⋯ 메뉴는 없다
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { MAIL_SCHEDULED_ROUTE } from '@/shared/utils/mail-list-source'
|
||||
|
||||
interface Props {
|
||||
count: number
|
||||
/** 지금 예약됨 목록 · 예약됨에서 연 메일을 보고 있다 */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['scheduled-mailbox', { 'is-active': active }]">
|
||||
<RouterLink
|
||||
:to="{ name: MAIL_SCHEDULED_ROUTE }"
|
||||
class="scheduled-mailbox__link"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
data-scheduled-mailbox
|
||||
>
|
||||
<AppIcon name="clock" />
|
||||
<span class="scheduled-mailbox__name">{{ SEND_LATER_TEXT.mailbox }}</span>
|
||||
<span
|
||||
v-if="count > 0"
|
||||
class="scheduled-mailbox__count"
|
||||
>{{ formatCount(count) }}<span class="visually-hidden">통</span></span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메일함 줄(MailboxPanelRow)과 같은 높이 · 여백 · 현재 표시 */
|
||||
.scheduled-mailbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-item);
|
||||
}
|
||||
|
||||
.scheduled-mailbox:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.scheduled-mailbox.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.scheduled-mailbox__link {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
/* 오른쪽 — 메일함 줄의 ⋯ 자리(32)만큼 비워 숫자 끝을 맞춘다 */
|
||||
padding: 0 calc(var(--space-4) + var(--size-mailbox-more)) 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.scheduled-mailbox:hover .scheduled-mailbox__link {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.scheduled-mailbox.is-active .scheduled-mailbox__link {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scheduled-mailbox__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scheduled-mailbox__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.scheduled-mailbox.is-active .scheduled-mailbox__count {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -2,10 +2,13 @@
|
||||
// 메일함 목록 두 묶음 — 기본 메일함 · "내 메일함"(12px 굵게) + 끝의 [+ 새 메일함](44) (디자인 원본 MailboxManage)
|
||||
// 하위 메일함은 뿌리의 묶음을 따라 16px 씩 들여 쓴다 (mailbox.ts 의 splitMailboxGroups). 줄 하나는 MailboxPanelRow.
|
||||
// "내 메일함" 이 비어도 묶음 이름과 [+ 새 메일함]은 둔다 — 첫 메일함을 만들 수 있게
|
||||
// 「예약됨」(9단계 — 예약한 메일이 있을 때만)은 기본 메일함 묶음의 보낸편지함(과 그 하위) 바로 아래 — 보낸편지함이 없으면 묶음 끝.
|
||||
// 예약됨을 보는 동안은 수가 0 이 되어도 남긴다 — 다 취소하거나 보낸 뒤에도 지금 있는 곳이 패널에 보이게
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxPanelRow from '@/components/mail/MailboxPanelRow.vue'
|
||||
import MailboxScheduledRow from '@/components/mail/MailboxScheduledRow.vue'
|
||||
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { splitMailboxGroups, type MailboxAction, type MailboxNode } from '@/shared/utils/mailbox'
|
||||
@@ -14,6 +17,10 @@ interface Props {
|
||||
nodes: readonly MailboxNode[]
|
||||
/** 지금 보는 메일함 — 쓰기 화면에서는 null */
|
||||
activeId: string | null
|
||||
/** 예약한 메일 수 — 0 이면 「예약됨」을 그리지 않는다 (9단계) */
|
||||
scheduledCount?: number
|
||||
/** 지금 예약됨을 보고 있다 */
|
||||
scheduledActive?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -21,24 +28,56 @@ interface Emits {
|
||||
(e: 'create'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scheduledCount: 0,
|
||||
scheduledActive: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const MY_GROUP_ID = 'mailbox-panel-mine'
|
||||
/** 보낸편지함이 없을 때 — 「예약됨」을 기본 메일함 묶음 끝에 (메일함 id 는 비지 않는다) */
|
||||
const AT_GROUP_END = ''
|
||||
|
||||
const groups = computed(() => splitMailboxGroups(props.nodes))
|
||||
/**
|
||||
* 「예약됨」을 넣을 자리 — 기본 메일함 묶음에서 이 메일함 줄 뒤(보낸편지함 하위의 끝). 보낸편지함이 없으면 묶음 끝(AT_GROUP_END).
|
||||
* 예약한 메일이 없으면 null (그리지 않는다) — 다만 예약됨을 보고 있으면 남긴다
|
||||
*/
|
||||
const scheduledAfterId = computed<string | null>(() => {
|
||||
if (props.scheduledCount <= 0 && !props.scheduledActive) return null
|
||||
const system = groups.value.system
|
||||
const sentIndex = system.findIndex((node) => node.mailbox.role === 'sent')
|
||||
const sent = system[sentIndex]
|
||||
if (!sent) return AT_GROUP_END
|
||||
let last = sentIndex
|
||||
while ((system[last + 1]?.depth ?? -1) > sent.depth) last += 1
|
||||
return system[last]?.mailbox.id ?? AT_GROUP_END
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mailbox-tree">
|
||||
<ul class="mailbox-tree__list">
|
||||
<MailboxPanelRow
|
||||
<template
|
||||
v-for="node in groups.system"
|
||||
:key="node.mailbox.id"
|
||||
:mailbox="node.mailbox"
|
||||
:depth="node.depth"
|
||||
:active="node.mailbox.id === activeId"
|
||||
@action="(action, mailbox) => emit('action', action, mailbox)"
|
||||
>
|
||||
<MailboxPanelRow
|
||||
:mailbox="node.mailbox"
|
||||
:depth="node.depth"
|
||||
:active="node.mailbox.id === activeId"
|
||||
@action="(action, mailbox) => emit('action', action, mailbox)"
|
||||
/>
|
||||
<MailboxScheduledRow
|
||||
v-if="scheduledAfterId === node.mailbox.id"
|
||||
:count="scheduledCount"
|
||||
:active="scheduledActive"
|
||||
/>
|
||||
</template>
|
||||
<MailboxScheduledRow
|
||||
v-if="scheduledAfterId === AT_GROUP_END"
|
||||
:count="scheduledCount"
|
||||
:active="scheduledActive"
|
||||
/>
|
||||
</ul>
|
||||
<p
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
// 보낸 사람 차단 확인 창 (디자인 원본 MailReadTools · README "9단계 › 차단 확인 창") — 모달 400(medium)
|
||||
// 제목 "보낸 사람 차단" · 설명 → 고르기 둘(「이 주소만」 주소 · 「이 도메인 전체」 @도메인 에서 오는 메일 모두) →
|
||||
// 체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐 — 이미 스팸메일함의 메일이면 두지 않는다) → 안내 → [차단](처음 초점 — 되돌릴 수 있는 일) [취소]
|
||||
// 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등 — 서버와 같은 목록)는 「이 도메인 전체」를 끄고 그 아래에 까닭을 둔다.
|
||||
// 열 때마다 「이 주소만」 · 옮기기 켜짐으로 시작한다. Esc 는 [취소]와 같다
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
|
||||
import { normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import { domainBlockOf, emailDomainOf, isPublicMailDomain } from '@/shared/utils/mail-block'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 보낸 사람 주소 */
|
||||
senderEmail: string
|
||||
/** 이 메일이 이미 스팸메일함에 있다 — 옮기기 칸을 두지 않는다 */
|
||||
inJunk?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** target 은 주소 또는 "@도메인" (소문자) */
|
||||
(e: 'confirm', target: string, moveToJunk: boolean): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
inJunk: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const scope = ref<'address' | 'domain'>('address')
|
||||
const moveToJunk = ref<boolean>(true)
|
||||
|
||||
const address = computed(() => normalizeEmail(props.senderEmail))
|
||||
const domain = computed(() => emailDomainOf(props.senderEmail))
|
||||
const domainTarget = computed(() => domainBlockOf(domain.value))
|
||||
/** 「이 도메인 전체」를 고를 수 없다 — 여러 사람이 쓰는 메일 서비스 · 도메인이 없는 주소 */
|
||||
const domainDisabled = computed(() => domain.value === '' || isPublicMailDomain(domain.value))
|
||||
|
||||
function confirm() {
|
||||
const target = scope.value === 'domain' && !domainDisabled.value ? domainTarget.value : address.value
|
||||
emit('confirm', target, moveToJunk.value && !props.inJunk)
|
||||
}
|
||||
|
||||
// 열 때마다 처음 값으로 — 「이 주소만」 · 옮기기 켜짐
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
scope.value = 'address'
|
||||
moveToJunk.value = true
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
:title="MAIL_BLOCK_TEXT.dialogTitle"
|
||||
:message="MAIL_BLOCK_TEXT.dialogMessage"
|
||||
title-id="block-dialog-title"
|
||||
message-id="block-dialog-message"
|
||||
medium
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<div
|
||||
class="block-dialog"
|
||||
data-block-dialog
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
class="block-dialog__scopes"
|
||||
:aria-label="MAIL_BLOCK_TEXT.scopeGroup"
|
||||
>
|
||||
<label :class="['block-dialog__scope', { 'is-checked': scope === 'address' }]">
|
||||
<input
|
||||
v-model="scope"
|
||||
type="radio"
|
||||
name="block-dialog-scope"
|
||||
value="address"
|
||||
class="block-dialog__input"
|
||||
data-block-scope="address"
|
||||
>
|
||||
<span class="block-dialog__scope-text">
|
||||
<span class="block-dialog__scope-label">{{ MAIL_BLOCK_TEXT.scopeAddress }}</span>
|
||||
<span class="block-dialog__scope-hint">{{ address }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
:class="[
|
||||
'block-dialog__scope',
|
||||
{ 'is-checked': scope === 'domain' && !domainDisabled, 'is-disabled': domainDisabled },
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="scope"
|
||||
type="radio"
|
||||
name="block-dialog-scope"
|
||||
value="domain"
|
||||
class="block-dialog__input"
|
||||
:disabled="domainDisabled"
|
||||
:aria-describedby="domainDisabled && domain ? 'block-dialog-domain-note' : undefined"
|
||||
data-block-scope="domain"
|
||||
>
|
||||
<span class="block-dialog__scope-text">
|
||||
<span class="block-dialog__scope-label">{{ MAIL_BLOCK_TEXT.scopeDomain }}</span>
|
||||
<span class="block-dialog__scope-hint">{{ MAIL_BLOCK_TEXT.scopeDomainHint(domainTarget) }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="domainDisabled && domain"
|
||||
id="block-dialog-domain-note"
|
||||
class="form-message block-dialog__note"
|
||||
>
|
||||
{{ MAIL_BLOCK_TEXT.scopeDomainPublic }}
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
v-if="!inJunk"
|
||||
class="block-dialog__move"
|
||||
>
|
||||
<input
|
||||
v-model="moveToJunk"
|
||||
type="checkbox"
|
||||
class="block-dialog__input"
|
||||
data-block-move
|
||||
>
|
||||
<span>{{ MAIL_BLOCK_TEXT.moveToJunk }}</span>
|
||||
</label>
|
||||
<p class="form-message">
|
||||
{{ MAIL_BLOCK_TEXT.settingsHint }}
|
||||
</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
autofocus
|
||||
data-block-confirm
|
||||
@click="confirm"
|
||||
>
|
||||
{{ MAIL_BLOCK_TEXT.confirm }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
data-block-cancel
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAIL_BLOCK_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.block-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.block-dialog__scopes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* 고르기 줄 — 44 이상 · 테두리 상자, 고른 줄은 청록 테두리 + 옅은 바탕 (디자인 원본 MailReadTools) */
|
||||
.block-dialog__scope {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
padding: var(--space-10);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.block-dialog__scope.is-checked {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.block-dialog__scope.is-disabled {
|
||||
color: var(--color-icon-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.block-dialog__input {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: var(--space-1) 0 0;
|
||||
accent-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.block-dialog__scope-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--line-height-heading);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.block-dialog__scope-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.block-dialog__scope-hint {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.block-dialog__scope.is-disabled .block-dialog__scope-hint {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
/* 「이 도메인 전체」를 끈 까닭 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
|
||||
.block-dialog__note {
|
||||
margin-left: var(--space-28);
|
||||
}
|
||||
|
||||
.block-dialog__move {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.block-dialog__move .block-dialog__input {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -3,16 +3,23 @@
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval)
|
||||
// · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다."
|
||||
// - 아직 보내지 않은 메일(9단계 — pendingSend 가 있다: 보내기 취소를 기다리는 중 · 예약): 확인 대화상자(처음 초점은 「취소」 — 발송 취소는
|
||||
// 되돌릴 수 없다)를 거쳐 발송을 취소한 뒤 휴지통으로 — "보내지 않고 휴지통으로 옮겼습니다." (useScheduledMessageActions).
|
||||
// 취소하지 않고 휴지통으로 옮기면 엔진은 그 시각에 그대로 보낸다 (2026-10-29 리뷰)
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
// 그사이 다른 목록 · 다른 메일로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { useScheduledMessageActions } from '@/composables/useScheduledMessageActions'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { isHeldMessage } from '@/shared/utils/pending-send'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -25,35 +32,61 @@ const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const removal = useMessageRemoval()
|
||||
const scheduledActions = useScheduledMessageActions()
|
||||
|
||||
const working = ref<boolean>(false)
|
||||
const confirmOpen = ref<boolean>(false)
|
||||
/** 열린 확인 창 — 누른 때의 메일 상태로 정한다 (창을 연 채 상태가 바뀌면 닫는다 — 아래 watch) */
|
||||
const confirming = ref<'held' | 'purge' | null>(null)
|
||||
|
||||
/** 아직 보내지 않은 메일 — 발송을 취소한 뒤 휴지통으로 */
|
||||
const held = computed(() => isHeldMessage(props.message))
|
||||
/** 「영구 삭제」할 메일인가 — 휴지통 목록 밖에서는 휴지통에만 든 메일만 (useMessageRemoval) */
|
||||
const permanent = computed(() => removal.deletesPermanently(props.message))
|
||||
const permanent = computed(() => !held.value && removal.deletesPermanently(props.message))
|
||||
/**
|
||||
* 그려 둘 확인 창 — 누를 때 정한다. 그사이 발송이 풀리거나 창을 닫아도 바꾸지 않는다 —
|
||||
* 닫히는 창이 그대로 남아 초점을 이 단추로 돌려준다 (ModalDialog)
|
||||
*/
|
||||
const dialogKind = ref<'held' | 'purge'>(held.value ? 'held' : 'purge')
|
||||
|
||||
async function run(kind: 'trash' | 'purge') {
|
||||
async function run(kind: 'trash' | 'purge' | 'held') {
|
||||
const target = props.message
|
||||
const source = mailStore.listSource
|
||||
working.value = true
|
||||
try {
|
||||
if (kind === 'held') {
|
||||
await scheduledActions.deletePendingSend(target, props.layout)
|
||||
return
|
||||
}
|
||||
const outcome = kind === 'trash' ? await removal.moveToTrash(target) : await removal.purge(target)
|
||||
await removal.leaveRemoved(outcome, source, props.layout)
|
||||
await removal.leaveRemoved(outcome, source, props.layout, target.id)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openConfirm(kind: 'held' | 'purge') {
|
||||
dialogKind.value = kind
|
||||
confirming.value = kind
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
if (working.value) return
|
||||
if (permanent.value) confirmOpen.value = true
|
||||
if (held.value) openConfirm('held')
|
||||
else if (permanent.value) openConfirm('purge')
|
||||
else void run('trash')
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
confirmOpen.value = false
|
||||
void run('purge')
|
||||
const kind = confirming.value
|
||||
confirming.value = null
|
||||
if (kind) void run(kind)
|
||||
}
|
||||
|
||||
// 확인 창을 연 채 그 메일이 바뀌면 닫는다 — 엔진이 발송을 풀어 보낸 메일이 됐거나(9단계) 다른 메일 · 메일함으로 바뀌었다.
|
||||
// 연 때와 다른 일(보낸 메일의 영구 삭제 확인 등)을 묻지 않게
|
||||
watch([held, permanent, () => props.message.id], () => {
|
||||
confirming.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,11 +98,42 @@ function onConfirm() {
|
||||
data-reader-delete
|
||||
@click="onClick"
|
||||
/>
|
||||
<ModalDialog
|
||||
v-if="dialogKind === 'held'"
|
||||
:open="confirming === 'held'"
|
||||
role="alertdialog"
|
||||
:title="SEND_LATER_TEXT.deleteTitle"
|
||||
:message="SEND_LATER_TEXT.deleteMessage"
|
||||
title-id="reader-scheduled-delete-title"
|
||||
message-id="reader-scheduled-delete-message"
|
||||
@cancel="confirming = null"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
data-scheduled-delete-confirm
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.deleteConfirm }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
data-scheduled-delete-cancel
|
||||
@click="confirming = null"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.deleteCancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
<MailPurgeDialog
|
||||
:open="confirmOpen"
|
||||
v-else
|
||||
:open="confirming === 'purge'"
|
||||
:count="1"
|
||||
id-prefix="reader-purge"
|
||||
@confirm="onConfirm"
|
||||
@cancel="confirmOpen = false"
|
||||
@cancel="confirming = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -134,6 +134,27 @@ describe('MessageList (분할 보기 목록)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('아직 보내지 않은 메일(9단계)은 고를 수 없다 — 꺼진 상자(aria-disabled · 까닭 title)를 눌러도 · Ctrl(⌘)+클릭도 고르지 않고, 그냥 누르면 연다 (리뷰 #1)', async () => {
|
||||
const rows = [
|
||||
summary('u1', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' } }),
|
||||
summary('m1', { mailboxIds: ['sent'] }),
|
||||
]
|
||||
const { wrapper, router, store } = await mountList(SENT, rows)
|
||||
const box = wrapper.findAll('.message-row [role="checkbox"]')[0]
|
||||
|
||||
expect(box?.attributes('aria-disabled')).toBe('true')
|
||||
expect(box?.attributes('title')).toBe('보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.')
|
||||
await box?.trigger('click')
|
||||
await wrapper.findAll('.message-row__main')[0]?.trigger('click', { metaKey: true })
|
||||
await flushPromises()
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
|
||||
await wrapper.findAll('.message-row__main')[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/u1')
|
||||
})
|
||||
|
||||
it('전체 메일함 검색 결과 — 시각 앞에 "보낸편지함 ·", 보낸 메일은 "받는 사람: 이름", 누르면 검색 결과 안의 주소', async () => {
|
||||
const q = encodeURIComponent('회의')
|
||||
const { wrapper, store } = await mountList(
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
|
||||
// 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts — 임시보관함은 받는 사람 · 쓰기 화면에서 이어 쓰기)
|
||||
// 전체 메일함 검색 결과면 첫 줄 시각 앞에 "보낸편지함 ·" (디자인 원본 Search)
|
||||
// 아직 보내지 않은 내 메일(9단계)은 시각 자리에 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록 — 디자인 원본 MailSendLater)
|
||||
// 여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기"):
|
||||
// - 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다 (숨긴 동안에도 Tab 으로 닿는다)
|
||||
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
|
||||
// - 아직 보내지 않은 메일(9단계)은 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다
|
||||
// (옮기거나 지워도 엔진은 그 시각에 보낸다 — 열어서 취소하거나 삭제한다. 스토어도 건너뛴다 — selection.ts)
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
|
||||
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
|
||||
import { isHeldMessage } from '@/shared/utils/pending-send'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
@@ -51,15 +56,22 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'split' }))
|
||||
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
|
||||
const time = computed(() => formatMailListTime(props.message.receivedAt))
|
||||
/** 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
|
||||
const pendingLabel = computed(() => pendingSendLabel(props.message))
|
||||
const time = computed(() => pendingLabel.value ?? formatMailListTime(props.message.receivedAt))
|
||||
const unread = computed(() => isRowUnread(props.message, props.role))
|
||||
const draft = computed(() => isDraftRole(props.role))
|
||||
/** 아직 보내지 않은 메일 — 고를 수 없다 */
|
||||
const held = computed(() => isHeldMessage(props.message))
|
||||
|
||||
/** Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다) */
|
||||
/**
|
||||
* Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다).
|
||||
* 아직 보내지 않은 메일은 고르지 않는다
|
||||
*/
|
||||
function onMainClick(event: MouseEvent) {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) return
|
||||
event.preventDefault()
|
||||
emit('check', props.message.id, event.shiftKey)
|
||||
if (!held.value) emit('check', props.message.id, event.shiftKey)
|
||||
}
|
||||
|
||||
/** Shift + 누름이 글자를 고르지 않게 한다 */
|
||||
@@ -81,6 +93,8 @@ function onMouseDown(event: MouseEvent) {
|
||||
class="message-row__check"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
:unavailable="held"
|
||||
:note="SEND_LATER_TEXT.heldNotSelectable"
|
||||
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
|
||||
/>
|
||||
<RouterLink
|
||||
@@ -101,8 +115,8 @@ function onMouseDown(event: MouseEvent) {
|
||||
class="message-row__mailbox"
|
||||
>{{ mailboxName }} ·</span>
|
||||
<time
|
||||
class="message-row__time"
|
||||
:datetime="message.receivedAt"
|
||||
:class="['message-row__time', { 'is-pending': pendingLabel }]"
|
||||
:datetime="message.pendingSend?.sendAt ?? message.receivedAt"
|
||||
>{{ time }}</time>
|
||||
</span>
|
||||
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
|
||||
@@ -254,6 +268,13 @@ function onMouseDown(event: MouseEvent) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 아직 보내지 않은 내 메일 — 청록 굵게 (디자인 원본 MailSendLater) */
|
||||
.message-row__time.is-pending,
|
||||
.message-row.is-unread .message-row__time.is-pending {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.message-row__bottom {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
|
||||
// 그 아래 둘째 묶음에 「보낸 사람을 주소록에 추가」(8단계 — 디자인 원본 ContactsMail · 계약 3절 "메일과 함께") ·
|
||||
// 「이런 메일로 필터 만들기」(깔때기 — 디자인 원본 MailFilterStates · 계약 2절 "메일 필터"의 "클라이언트")
|
||||
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴 (디자인 원본 MailReadTools · README "9단계 › ⋯ 메뉴"):
|
||||
// 「인쇄」(Ctrl+P 표시 — 9단계) → 「원문 내려받기 (.eml)」 → 구분선 → 「보낸 사람을 주소록에 추가」/「주소록에서 보기」(8단계) →
|
||||
// 「이런 메일로 필터 만들기」(6단계) → 「보낸 사람 차단」(빨간 글 — 9단계) / 「보낸 사람 차단 풀기」
|
||||
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
|
||||
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
|
||||
// 인쇄: 새 탭에 인쇄 화면을 연다(useMailPrint — 외부 이미지를 보이고 있었으면 그대로)
|
||||
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
|
||||
// 주소록: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」 — 이름 · 주소를 채운 연락처 추가 창을 이 화면에서 연다
|
||||
// (창은 메일 레이아웃에 있다 — useMailContactDialog. 메일을 바꿔도 창이 남는다).
|
||||
@@ -11,43 +12,61 @@
|
||||
// 결과가 오기 전에는 「추가」로 둔다 (저장할 때 같은 주소가 있으면 창 안에 「이미 주소록에 있는 주소」가 보인다).
|
||||
// 연락처가 3,000개면 「추가」를 끄고 까닭을 아래에 (수를 모르거나 3,000개로 받아 두었으면 열 때 다시 받아 본다)
|
||||
// 필터 만들기는 설정 › 필터로 간다 — /settings/mail/filters?from=<보낸 사람 주소> (새 필터 창이 그 주소로 채워져 열린다).
|
||||
// 보낸 사람 주소가 없는 메일에는 두 항목을 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
|
||||
// 보낸 사람 주소가 없는 메일에는 주소록 · 필터 · 차단 항목을 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
|
||||
// 메뉴를 열 때 필터를 아직 받지 않았으면 받아 본다(알리지 않는다 — 모르는 동안은 켜 두고, 설정 화면이 다시 본다)
|
||||
// 차단: 사내 직원(사내 도메인) · 내 주소에는 항목이 없다. 이미 막았으면(주소 또는 도메인) 「보낸 사람 차단 풀기」(바로 푼다 + [되돌리기]),
|
||||
// 아니면 「보낸 사람 차단」 → 확인 창(MessageBlockDialog). 500개면 끄고 까닭을 아래에. 차단 목록은 메일 화면에 들어올 때 받아 두고,
|
||||
// 메뉴를 열 때 못 받았으면 다시 받아 본다(알리지 않는다). 막으며 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로 간다
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MessageBlockDialog from '@/components/mail/MessageBlockDialog.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { useMailContactDialog } from '@/composables/useMailContactDialog'
|
||||
import { useMailPrint } from '@/composables/useMailPrint'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { useSenderBlock } from '@/composables/useSenderBlock'
|
||||
import { CONTACT_READER_TEXT, CONTACTS_PANEL_TEXT } from '@/shared/constants/contacts'
|
||||
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
|
||||
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import { MAIL_PRINT_TEXT } from '@/shared/constants/mail-print'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import type { Contact } from '@/shared/types/contacts'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { contactLocation } from '@/shared/utils/contacts-route'
|
||||
import { normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import { isUnblockableSender } from '@/shared/utils/mail-block'
|
||||
import { isHeldMessage } from '@/shared/utils/pending-send'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
import { useMailBlockStore } from '@/stores/mail-block.store'
|
||||
import { useMailFilterStore } from '@/stores/mail-filter.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
messageId: string
|
||||
/** 보낸 사람 주소 — 없으면 주소록 · 필터 항목을 두지 않는다 */
|
||||
senderEmail?: string | null
|
||||
/** 보낸 사람 표시 이름 — 주소록에 추가할 때 이름 칸을 채운다 */
|
||||
senderName?: string | null
|
||||
message: MessageDetail
|
||||
/** split: 차단하며 옮기면 다음 메일을 연다 · list: 목록으로 */
|
||||
layout?: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
senderEmail: null,
|
||||
senderName: null,
|
||||
layout: 'split',
|
||||
})
|
||||
|
||||
const { downloadMessageSource } = useMail()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
const filterStore = useMailFilterStore()
|
||||
const contactsStore = useContactsStore()
|
||||
const blockStore = useMailBlockStore()
|
||||
const contactDialog = useMailContactDialog()
|
||||
const print = useMailPrint()
|
||||
const senderBlock = useSenderBlock()
|
||||
const removal = useMessageRemoval()
|
||||
const router = useRouter()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
@@ -56,11 +75,30 @@ const open = ref<boolean>(false)
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
/** 보낸 사람의 연락처 — 내 주소록에 있으면 그 연락처, 없거나 아직 모르면 null */
|
||||
const senderContact = ref<Contact | null>(null)
|
||||
/** 차단 확인 창 */
|
||||
const blockDialogOpen = ref<boolean>(false)
|
||||
let lookupSeq = 0
|
||||
|
||||
const sender = computed(() => props.senderEmail?.trim() ?? '')
|
||||
const sender = computed(() => props.message.from?.email?.trim() ?? '')
|
||||
/**
|
||||
* 차단할 수 있는 보낸 사람 — 주소가 있고 내 주소 · 사내 직원이 아니다. 아직 보내지 않은 메일(9단계 — 내 메일이라 보통 차단 항목이 없다)에도 두지 않는다:
|
||||
* 차단 창은 그 메일을 스팸메일함으로 옮기는데, 붙잡은 발송은 옮겨도 그 시각에 보내진다
|
||||
*/
|
||||
const blockable = computed(
|
||||
() =>
|
||||
sender.value !== '' &&
|
||||
!isHeldMessage(props.message) &&
|
||||
!isUnblockableSender(sender.value, authStore.user?.email ?? ''),
|
||||
)
|
||||
/** 이미 막고 있는 차단 (주소 또는 도메인) */
|
||||
const senderBlockEntry = computed(() => (blockable.value ? blockStore.blockOf(sender.value) : null))
|
||||
const inJunk = computed(() => removal.isInJunk(props.message))
|
||||
|
||||
const items = computed<EditorMenuItem[]>(() => {
|
||||
const list: EditorMenuItem[] = [{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' }]
|
||||
const list: EditorMenuItem[] = [
|
||||
{ id: 'print', label: MAIL_PRINT_TEXT.menu, icon: 'printer', hint: print.shortcut.label },
|
||||
{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' },
|
||||
]
|
||||
if (!sender.value) return list
|
||||
list.push(menuSeparator('sender'))
|
||||
if (senderContact.value) {
|
||||
@@ -81,6 +119,19 @@ const items = computed<EditorMenuItem[]>(() => {
|
||||
disabled: filterStore.isFull,
|
||||
note: filterStore.isFull ? MAIL_FILTER_TEXT.fullNote : undefined,
|
||||
})
|
||||
if (!blockable.value) return list
|
||||
if (senderBlockEntry.value) {
|
||||
list.push({ id: 'unblock', label: MAIL_BLOCK_TEXT.menuUnblock, icon: 'block' })
|
||||
} else {
|
||||
list.push({
|
||||
id: 'block',
|
||||
label: MAIL_BLOCK_TEXT.menuBlock,
|
||||
icon: 'block',
|
||||
tone: 'danger',
|
||||
disabled: blockStore.isFull,
|
||||
note: blockStore.isFull ? MAIL_BLOCK_TEXT.fullNote : undefined,
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
@@ -104,6 +155,8 @@ function openMenu(keyboard: boolean) {
|
||||
// 주소록에 있는지 · 연락처가 3,000개인지 (알리지 않는다)
|
||||
void lookUpSender()
|
||||
contactsStore.ensureContactTotal()
|
||||
// 차단했는지 · 500개인지 — 막을 수 있는 보낸 사람일 때만, 받아 둔 것이 없으면 (알리지 않는다)
|
||||
if (blockable.value) blockStore.ensureLoaded()
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
@@ -129,14 +182,20 @@ function onClose(restoreFocus: boolean) {
|
||||
function onSelect(id: string) {
|
||||
open.value = false
|
||||
buttonRef.value?.focus()
|
||||
if (id === 'source') void downloadMessageSource(props.messageId)
|
||||
if (id === 'print') print.openPrint(props.message)
|
||||
if (id === 'source') void downloadMessageSource(props.message.id)
|
||||
if (id === 'contact-view' && senderContact.value) void router.push(contactLocation(senderContact.value.id))
|
||||
if (id === 'contact-add' && sender.value && !contactsStore.isFull) {
|
||||
contactDialog?.open({ name: props.senderName?.trim() || null, email: sender.value, label: 'work' }, focusButton)
|
||||
contactDialog?.open(
|
||||
{ name: props.message.from?.name?.trim() || null, email: sender.value, label: 'work' },
|
||||
focusButton,
|
||||
)
|
||||
}
|
||||
if (id === 'filter' && sender.value && !filterStore.isFull) {
|
||||
void router.push({ name: SETTINGS_ROUTES.filters, query: { [MAIL_FILTER_FROM_QUERY]: sender.value } })
|
||||
}
|
||||
if (id === 'block' && blockable.value && !blockStore.isFull) blockDialogOpen.value = true
|
||||
if (id === 'unblock' && senderBlockEntry.value) void senderBlock.unblock(senderBlockEntry.value.address)
|
||||
}
|
||||
|
||||
/** 연락처 창을 닫은 뒤 — 이 단추가 아직 있으면 초점을 돌려준다 (다른 메일로 바뀌어 단추가 사라졌으면 그대로) */
|
||||
@@ -145,12 +204,36 @@ function focusButton() {
|
||||
if (button?.isConnected) button.focus()
|
||||
}
|
||||
|
||||
// 다른 메일로 바뀌면 지난 보낸 사람의 결과를 버린다
|
||||
/**
|
||||
* 차단 확인 창의 [차단] — 창을 닫고 막는다. 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로.
|
||||
* 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면(stale · leaveRemoved 의 stillShowing) 움직이지 않는다
|
||||
*/
|
||||
async function onBlock(target: string, moveToJunk: boolean) {
|
||||
blockDialogOpen.value = false
|
||||
const message = props.message
|
||||
const source = mailStore.listSource
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const outcome = await senderBlock.block(message, target, moveToJunk)
|
||||
if (!outcome.moved || outcome.stale) return
|
||||
const removed = !mailStore.messages.some((item) => item.id === message.id)
|
||||
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
|
||||
}
|
||||
|
||||
// 다른 메일로 바뀌면 지난 보낸 사람의 결과를 버린다 (확인 창도 닫는다 — 다른 메일의 보낸 사람을 막지 않게)
|
||||
watch(
|
||||
() => normalizeEmail(sender.value),
|
||||
() => {
|
||||
lookupSeq += 1
|
||||
senderContact.value = null
|
||||
blockDialogOpen.value = false
|
||||
},
|
||||
)
|
||||
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료) 확인 창을 닫는다 — 세션 만료 대화상자와 겹치지 않게
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
blockDialogOpen.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -182,6 +265,13 @@ watch(
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
<MessageBlockDialog
|
||||
:open="blockDialogOpen"
|
||||
:sender-email="sender"
|
||||
:in-junk="inJunk"
|
||||
@confirm="onBlock"
|
||||
@cancel="blockDialogOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -210,9 +300,9 @@ watch(
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 메뉴 폭 280 (디자인 원본 ContactsMail) — 필터 50개 · 연락처 3,000개의 까닭(note)은 그 안에서 줄을 바꾼다 */
|
||||
/* 메뉴 폭 280~300 (디자인 원본 ContactsMail · MailReadTools) — 필터 50개 · 연락처 3,000개 · 차단 500개의 까닭(note)은 그 안에서 줄을 바꾼다 */
|
||||
.reader-more__menu {
|
||||
min-width: 12.5rem;
|
||||
max-width: 17.5rem;
|
||||
max-width: 18.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -622,7 +622,9 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
await more.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
|
||||
// 첫 항목은 인쇄(9단계 — 단축키 표시), 사내 직원의 메일이라 「보낸 사람 차단」은 없다
|
||||
expect(items.map((item) => item.text())).toEqual([
|
||||
'인쇄Ctrl+P',
|
||||
'원문 내려받기 (.eml)',
|
||||
'보낸 사람을 주소록에 추가',
|
||||
'이런 메일로 필터 만들기',
|
||||
@@ -631,7 +633,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
expect(wrapper.findAll('[role="menu"] hr')).toHaveLength(1)
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
|
||||
await items[0]?.trigger('click')
|
||||
await items[1]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다
|
||||
@@ -655,7 +657,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[role="menuitem"]').trigger('click')
|
||||
await wrapper.findAll('[role="menuitem"]')[1]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(downloads).toEqual([])
|
||||
@@ -667,7 +669,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const item = wrapper.findAll('[role="menuitem"]')[2]
|
||||
const item = wrapper.findAll('[role="menuitem"]')[3]
|
||||
expect(item?.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
|
||||
expect(item?.attributes('disabled')).toBeUndefined()
|
||||
|
||||
@@ -693,7 +695,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
await wrapper.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menuitem"]')
|
||||
const item = items[2]
|
||||
const item = items[3]
|
||||
// 꺼진 항목은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점을 받는다
|
||||
expect(item?.attributes('aria-disabled')).toBe('true')
|
||||
expect(item?.attributes('disabled')).toBeUndefined()
|
||||
@@ -703,6 +705,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
await items[0]?.trigger('keydown', { key: 'ArrowDown' })
|
||||
await items[1]?.trigger('keydown', { key: 'ArrowDown' })
|
||||
await items[2]?.trigger('keydown', { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(item?.element)
|
||||
|
||||
await item?.trigger('click')
|
||||
@@ -720,7 +723,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
},
|
||||
})
|
||||
const more = wrapper.get('[data-reader-more]')
|
||||
const filterItem = () => wrapper.findAll('[role="menuitem"]')[2]
|
||||
const filterItem = () => wrapper.findAll('[role="menuitem"]')[3]
|
||||
const filterGets = () => requests.filter((request) => request.url === '/mail-filters')
|
||||
|
||||
await more.trigger('click')
|
||||
@@ -749,7 +752,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
|
||||
expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['인쇄Ctrl+P', '원문 내려받기 (.eml)'])
|
||||
expect(wrapper.find('[role="menu"] hr').exists()).toBe(false)
|
||||
expect(requests.some((request) => request.url === '/mail-filters')).toBe(false)
|
||||
expect(requests.some((request) => request.url === '/contacts')).toBe(false)
|
||||
@@ -767,7 +770,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
note: null,
|
||||
groupIds: [],
|
||||
}
|
||||
const contactItem = (wrapper: ReturnType<typeof mount>) => wrapper.findAll('[role="menuitem"]')[1]
|
||||
const contactItem = (wrapper: ReturnType<typeof mount>) => wrapper.findAll('[role="menuitem"]')[2]
|
||||
|
||||
it('내 주소록에 없으면 「보낸 사람을 주소록에 추가」 — 보낸 사람의 이름 · 주소를 채운 연락처 추가 창을 이 화면에서 연다', async () => {
|
||||
const { wrapper, router } = await mountWith({ routeView: true })
|
||||
@@ -855,7 +858,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
const failing = await mountWith({ contacts: () => fail(503, 'MAIL_001') })
|
||||
await failing.wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(failing.wrapper.findAll('[role="menuitem"]')[1]?.text()).toBe('보낸 사람을 주소록에 추가')
|
||||
expect(failing.wrapper.findAll('[role="menuitem"]')[2]?.text()).toBe('보낸 사람을 주소록에 추가')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { BlockedSender, Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
|
||||
import { formatScheduleTime } from '@/shared/utils/format'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailBlockStore } from '@/stores/mail-block.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageReader from './MessageReader.vue'
|
||||
|
||||
// 읽기 화면의 9단계 — 외부 이미지 [이미지 보기] · 예약한 메일(안내 줄 · 도구 줄 · 삭제) · 보낸 사람 차단(⋯ · 확인 창 · 스팸메일함의 줄) · 인쇄
|
||||
// (디자인 원본 MailReadTools · MailSendLater · README "9단계" · 계약 2절 "외부 이미지 보기" · "보내기 취소 · 예약 발송" · "보낸 사람 차단" · "인쇄")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 }
|
||||
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent' }
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts' }
|
||||
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk' }
|
||||
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' }
|
||||
const MAILBOXES = [INBOX, SENT, DRAFTS, JUNK, TRASH]
|
||||
|
||||
/** 바깥에서 온 뉴스레터 — 외부 이미지를 막았다 */
|
||||
const NEWSLETTER: MessageDetail = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '클라우드 브리프', email: 'news@cloudbrief.example' },
|
||||
to: [USER],
|
||||
subject: '[클라우드 브리프] 10월 넷째 주',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-28T23:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend: null,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: '<p>안녕하세요, 이번 주 클라우드 브리프입니다.</p>',
|
||||
textBody: '안녕하세요',
|
||||
hasBlockedImages: true,
|
||||
imagesShown: false,
|
||||
attachments: [],
|
||||
}
|
||||
const SHOWN: MessageDetail = {
|
||||
...NEWSLETTER,
|
||||
htmlBody: '<p>안녕하세요, 이번 주 클라우드 브리프입니다.</p><img src="/api/mail-images/t0ken">',
|
||||
hasBlockedImages: false,
|
||||
imagesShown: true,
|
||||
}
|
||||
const SEND_AT = '2026-10-30T00:00:00.000Z'
|
||||
/** 내가 예약한 메일 — 보낸편지함 · 예약됨 */
|
||||
const SCHEDULED: MessageDetail = {
|
||||
...NEWSLETTER,
|
||||
id: 's1',
|
||||
mailboxIds: ['sent'],
|
||||
from: USER,
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
|
||||
subject: '11월 첫 주 배포 일정 확인 부탁드립니다',
|
||||
htmlBody: null,
|
||||
textBody: '도윤님, 확인 부탁드립니다.',
|
||||
hasBlockedImages: false,
|
||||
pendingSend: { kind: 'scheduled', sendAt: SEND_AT },
|
||||
}
|
||||
|
||||
/** 돌려줄 응답을 나중에 정하는 요청 */
|
||||
function deferred() {
|
||||
let resolve: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
message?: MessageDetail
|
||||
source?: MailListSource
|
||||
layout?: 'split' | 'list'
|
||||
blocks?: BlockedSender[]
|
||||
/** 그 밖의 요청 — 앞에서 처리하지 않은 것 */
|
||||
extra?: (request: MockRequest) => MockResult | undefined
|
||||
}
|
||||
|
||||
describe('MessageReader — 9단계', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let opened: unknown[][]
|
||||
/** 띄운 화면 — 읽기 화면은 window 에 Ctrl+P 처리기를 달므로 다음 테스트로 새지 않게 내린다 */
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession(USER)
|
||||
opened = []
|
||||
vi.spyOn(window, 'open').mockImplementation((...args: unknown[]) => {
|
||||
opened.push(args)
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountWith(options: MountOptions = {}) {
|
||||
const message = options.message ?? NEWSLETTER
|
||||
const source = options.source ?? { kind: 'mailbox', mailboxId: message.mailboxIds[0] ?? 'inbox' }
|
||||
requests = installMockApi((request) => {
|
||||
const handled = options.extra?.(request)
|
||||
if (handled) return handled
|
||||
if (request.url === '/mail-filters') return ok({ rules: [], version: 'v1', isActive: true })
|
||||
if (request.url === '/contacts') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
if (request.url === '/mail-blocks' && request.method === 'GET') {
|
||||
return ok({ senders: options.blocks ?? [], isActive: true })
|
||||
}
|
||||
if (request.url === '/mail-blocks' && request.method === 'POST') {
|
||||
return ok({ address: (request.data as { address: string }).address, blockedAt: '2026-10-29T06:00:00.000Z' }, 201)
|
||||
}
|
||||
if (request.url.startsWith('/mail-blocks/')) return ok({ address: decodeURIComponent(request.url.slice(13)) })
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
|
||||
if (request.url === '/scheduled-messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.listSource = source
|
||||
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = message
|
||||
store.selectedId = message.id
|
||||
store.selectedStatus = 'ready'
|
||||
if (options.blocks) await useMailBlockStore().load()
|
||||
const router = createTestRouter()
|
||||
const listPath = source.kind === 'scheduled' ? '/mail/scheduled' : `/mail/${message.mailboxIds[0] ?? 'inbox'}`
|
||||
await router.push(listPath)
|
||||
await router.push(`${listPath}/${message.id}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, {
|
||||
props: { layout: options.layout ?? 'split' },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
function runToastAction(index = 0) {
|
||||
const action = useToastStore().toasts[index]?.action
|
||||
if (!action || !('run' in action)) throw new Error('단추가 없다')
|
||||
action.run()
|
||||
}
|
||||
|
||||
describe('외부 이미지 — [이미지 보기] (36)', () => {
|
||||
it('노란 안내 줄의 [이미지 보기] → images=show 로 다시 받아 본문만 바꾸고 안내 줄은 사라진다 — 본문 틀이 중계 주소의 그림을 그린다', async () => {
|
||||
const { wrapper, store } = await mountWith({
|
||||
extra: (request) => (request.url === '/messages/m1' ? ok(SHOWN) : undefined),
|
||||
})
|
||||
const notice = wrapper.get('[data-notice="blockedImages"]')
|
||||
expect(notice.text()).toContain('외부 이미지를 차단했습니다.')
|
||||
const button = notice.get('[data-show-images]')
|
||||
expect(button.text()).toBe('이미지 보기')
|
||||
|
||||
await button.trigger('click')
|
||||
expect(button.attributes('aria-busy')).toBe('true')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.find((request) => request.url === '/messages/m1')?.params).toEqual({ images: 'show' })
|
||||
expect(wrapper.find('[data-notice="blockedImages"]').exists()).toBe(false)
|
||||
expect(store.selected?.imagesShown).toBe(true)
|
||||
const srcdoc = wrapper.get('iframe').attributes('srcdoc') ?? ''
|
||||
expect(srcdoc).toContain('src="/api/mail-images/t0ken"')
|
||||
expect(srcdoc).toContain(`img-src data: ${window.location.origin}/api/mail-images/;`)
|
||||
})
|
||||
|
||||
it('설정으로 처음부터 보이면(imagesShown) 안내 줄이 없다', async () => {
|
||||
const { wrapper } = await mountWith({ message: SHOWN })
|
||||
|
||||
expect(wrapper.find('[data-notice="blockedImages"]').exists()).toBe(false)
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('src="/api/mail-images/t0ken"')
|
||||
})
|
||||
|
||||
it('받는 사이 다른 메일로 바뀌면 늦은 응답은 본문을 바꾸지 않는다', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const late = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const { wrapper, store } = await mountWith({
|
||||
extra: (request) => {
|
||||
if (request.url === '/messages/m1') return late as unknown as MockResult
|
||||
if (request.url === '/messages/m2') return ok({ ...NEWSLETTER, id: 'm2', subject: '다음 메일' })
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-show-images]').trigger('click')
|
||||
await store.openMessage('m2')
|
||||
finish(ok(SHOWN))
|
||||
await flushPromises()
|
||||
|
||||
expect(store.selected?.id).toBe('m2')
|
||||
expect(store.selected?.imagesShown).toBe(false)
|
||||
expect(wrapper.find('[data-notice="blockedImages"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('인쇄 — ⋯ 「인쇄」(첫 항목 · Ctrl+P) · 읽기 화면의 Ctrl+P', () => {
|
||||
it('⋯ 「인쇄」는 새 탭(noopener)에 인쇄 화면을 연다 — 첫 항목 · 단축키 표시', async () => {
|
||||
const { wrapper } = await mountWith()
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const first = wrapper.findAll('[role="menuitem"]')[0]
|
||||
expect(first?.get('.menu__label').text()).toBe('인쇄')
|
||||
expect(first?.get('.menu__hint').text()).toBe('Ctrl+P')
|
||||
expect(first?.find('rect').exists()).toBe(true)
|
||||
await first?.trigger('click')
|
||||
|
||||
expect(opened).toEqual([['/mail/print/m1', '_blank', 'noopener']])
|
||||
})
|
||||
|
||||
it('외부 이미지를 보이고 있었으면 인쇄에도 그대로 — ?images=show', async () => {
|
||||
const { wrapper } = await mountWith({ message: SHOWN })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('[role="menuitem"]')[0]?.trigger('click')
|
||||
|
||||
expect(opened).toEqual([['/mail/print/m1?images=show', '_blank', 'noopener']])
|
||||
})
|
||||
|
||||
it('읽기 화면에서 Ctrl+P 는 브라우저 인쇄 대신 인쇄 화면을 연다 — 본문 안에서 눌러도, 다른 글쇠와 함께면 아니다', async () => {
|
||||
const { wrapper } = await mountWith()
|
||||
|
||||
const press = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, cancelable: true })
|
||||
window.dispatchEvent(press)
|
||||
expect(press.defaultPrevented).toBe(true)
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, shiftKey: true }))
|
||||
expect(opened).toEqual([['/mail/print/m1', '_blank', 'noopener']])
|
||||
|
||||
// 본문(iframe) 안에 초점이 있어도 — 본문 틀이 키를 알린다
|
||||
const frame = wrapper.get('iframe').element as HTMLIFrameElement
|
||||
frame.dispatchEvent(new Event('load'))
|
||||
const inFrame = new KeyboardEvent('keydown', { key: 'P', ctrlKey: true, bubbles: true, cancelable: true })
|
||||
frame.contentDocument?.dispatchEvent(inFrame)
|
||||
expect(inFrame.defaultPrevented).toBe(true)
|
||||
expect(opened).toHaveLength(2)
|
||||
|
||||
wrapper.unmount()
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true }))
|
||||
expect(opened).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('메일을 불러오는 중이면 Ctrl+P 를 가로채지 않는다', async () => {
|
||||
const { store } = await mountWith()
|
||||
store.selectedStatus = 'loading'
|
||||
store.selected = null
|
||||
|
||||
const press = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, cancelable: true })
|
||||
window.dispatchEvent(press)
|
||||
expect(press.defaultPrevented).toBe(false)
|
||||
expect(opened).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('예약한 메일 — 청록 안내 줄 · 답장 · 전달 · 스팸 신고 없음 · 삭제는 예약 취소 뒤 휴지통 (디자인 원본 MailSendLater)', () => {
|
||||
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
|
||||
|
||||
it('머리 아래 "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
|
||||
const { wrapper } = await mountWith({ message: SCHEDULED, source: { kind: 'scheduled' } })
|
||||
const notice = wrapper.get('[data-notice="scheduled"]')
|
||||
|
||||
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)} 에 보낼 메일입니다.`)
|
||||
expect(notice.findAll('button').map((button) => button.text())).toEqual(['고치기', '예약 취소'])
|
||||
expect(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual(['삭제'])
|
||||
expect(wrapper.find('[data-reader-more]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('[예약 취소] — 보내지 않고 임시보관함으로, "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기] · 다음 메일을 연다', async () => {
|
||||
const { wrapper, router, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => {
|
||||
if (request.method === 'DELETE') return ok({ draftId: 's1' })
|
||||
// 취소한 뒤 다시 맞추는 예약됨 목록 — 남은 메일
|
||||
if (request.url === '/scheduled-messages') {
|
||||
return ok({ items: [{ ...SCHEDULED, id: 'm2' }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(cancels().map((request) => request.url)).toEqual(['/scheduled-messages/s1'])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
|
||||
expect(useToastStore().toasts).toEqual([
|
||||
expect.objectContaining({
|
||||
message: '예약을 취소하고 임시보관함으로 옮겼습니다.',
|
||||
action: { label: '고치기', to: { name: 'mail-compose', query: { draft: 's1' } } },
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('[예약 취소] 뒤 그 자리에 남으면(검색 결과) 줄과 함께 사라진 단추 대신 본문으로 초점을 옮긴다 (리뷰 #8)', async () => {
|
||||
const { wrapper } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'search', q: '배포', scope: 'all' },
|
||||
extra: (request) => (request.method === 'DELETE' ? ok({ draftId: 's1' }) : undefined),
|
||||
})
|
||||
const button = wrapper.get('[data-scheduled-cancel]')
|
||||
;(button.element as HTMLButtonElement).focus()
|
||||
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('iframe').element)
|
||||
})
|
||||
|
||||
it('[고치기] — 예약을 취소하고 그 메일로 쓰기 화면을 연다', async () => {
|
||||
const { wrapper, router } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => (request.method === 'DELETE' ? ok({ draftId: 's1' }) : undefined),
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-edit]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(cancels()).toHaveLength(1)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=s1')
|
||||
expect(toastMessages()).toEqual(['예약을 취소했습니다. 고친 뒤 다시 보내 주세요.'])
|
||||
})
|
||||
|
||||
it('[고치기] 뒤 쓰기 화면으로 가지 못하면(이동이 막힘) 그 자리에 두고 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]', async () => {
|
||||
const { wrapper, router } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => (request.method === 'DELETE' ? ok({ draftId: 's1' }) : undefined),
|
||||
})
|
||||
router.beforeEach((to) => to.name !== 'mail-compose')
|
||||
|
||||
await wrapper.get('[data-scheduled-edit]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(cancels()).toHaveLength(1)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/s1')
|
||||
expect(useToastStore().toasts).toEqual([
|
||||
expect.objectContaining({
|
||||
message: '예약을 취소하고 임시보관함으로 옮겼습니다.',
|
||||
action: { label: '고치기', to: { name: 'mail-compose', query: { draft: 's1' } } },
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('이미 보냈으면(409) 아무 데도 가지 않는다 — 알림은 인터셉터', async () => {
|
||||
const { wrapper, router } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => (request.method === 'DELETE' ? fail(409, 'MAIL_014') : undefined),
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-edit]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/s1')
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
|
||||
})
|
||||
|
||||
it('[삭제] — 확인 창(처음 초점 「취소」) 뒤 예약을 취소하고 휴지통으로 옮긴다', async () => {
|
||||
const { wrapper } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => (request.method === 'DELETE' ? ok({ draftId: 's1' }) : undefined),
|
||||
})
|
||||
|
||||
await wrapper.get('[data-reader-delete]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#reader-scheduled-delete-title').text()).toBe('보내지 않은 메일을 삭제할까요?')
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-scheduled-delete-cancel]').element)
|
||||
expect(cancels()).toEqual([])
|
||||
|
||||
await wrapper.get('[data-scheduled-delete-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(cancels()).toHaveLength(1)
|
||||
expect(requests.find((request) => request.method === 'PATCH' && request.url === '/messages/s1')?.data).toEqual({
|
||||
mailboxId: 'trash',
|
||||
})
|
||||
expect(toastMessages()).toEqual(['보내지 않고 휴지통으로 옮겼습니다.'])
|
||||
})
|
||||
|
||||
it('[예약 취소] 를 기다리는 사이 다른 메일함으로 옮겨 갔으면 옛 목록의 다음 메일로 끌고 가지 않는다 (리뷰 #4)', async () => {
|
||||
const cancel = deferred()
|
||||
const { wrapper, router, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => (request.method === 'DELETE' ? (cancel.promise as unknown as MockResult) : undefined),
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-cancel]').trigger('click')
|
||||
// 기다리는 사이 받은편지함의 다른 메일을 연다
|
||||
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
|
||||
store.messages = [{ ...NEWSLETTER, id: 'i1' }]
|
||||
store.selectedId = 'i1'
|
||||
await router.push('/mail/inbox/i1')
|
||||
cancel.resolve(ok({ draftId: 's1' }))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/i1')
|
||||
// 취소는 됐다 — 알림은 띄운다
|
||||
expect(toastMessages()).toEqual(['예약을 취소하고 임시보관함으로 옮겼습니다.'])
|
||||
})
|
||||
|
||||
it('[삭제] 를 기다리는 사이 다른 메일을 열었으면 그 자리에 둔다 (리뷰 #4)', async () => {
|
||||
const cancel = deferred()
|
||||
const { wrapper, router, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'mailbox', mailboxId: 'sent' },
|
||||
extra: (request) => (request.method === 'DELETE' ? (cancel.promise as unknown as MockResult) : undefined),
|
||||
})
|
||||
|
||||
store.messages = [...store.messages, { ...NEWSLETTER, id: 'm3', mailboxIds: ['sent'] }]
|
||||
await wrapper.get('[data-reader-delete]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-scheduled-delete-confirm]').trigger('click')
|
||||
// 같은 목록의 다른 메일(m3 — 지운 메일의 다음 메일 m2 가 아니다)을 연다
|
||||
store.selectedId = 'm3'
|
||||
await router.push('/mail/sent/m3')
|
||||
cancel.resolve(ok({ draftId: 's1' }))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/m3')
|
||||
expect(toastMessages()).toEqual(['보내지 않고 휴지통으로 옮겼습니다.'])
|
||||
})
|
||||
|
||||
it('같은 메일의 취소를 거듭 해도 DELETE 는 하나다 — 두 번째가 거짓 "이미 보냄"을 띄우지 않는다 (리뷰 #9)', async () => {
|
||||
const cancel = deferred()
|
||||
const { wrapper, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => (request.method === 'DELETE' ? (cancel.promise as unknown as MockResult) : undefined),
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-cancel]').trigger('click')
|
||||
// 다른 길(예: 보낸 뒤 알림의 [보내기 취소])로 같은 메일을 한 번 더 취소한다
|
||||
const second = store.cancelPendingSend('s1')
|
||||
cancel.resolve(ok({ draftId: 's1' }))
|
||||
await flushPromises()
|
||||
|
||||
await expect(second).resolves.toEqual({ draftId: 's1' })
|
||||
expect(cancels()).toHaveLength(1)
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['success'])
|
||||
})
|
||||
|
||||
it('이미 보냈으면(409 MAIL_014) 보이는 목록 · 열린 메일 · 예약됨 수를 바로 다시 본다 — 남은 청록 줄이 내려간다 (리뷰 #2)', async () => {
|
||||
const { wrapper, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => {
|
||||
if (request.method === 'DELETE') return fail(409, 'MAIL_014')
|
||||
// 엔진이 이미 보냈다 — 예약됨에는 m2 만 남았다
|
||||
if (request.url === '/scheduled-messages') {
|
||||
return ok({ items: [{ ...SCHEDULED, id: 'm2' }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
}
|
||||
if (request.url === '/messages/s1') return ok({ ...SCHEDULED, pendingSend: null })
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'GET').map((request) => request.url)).toEqual(
|
||||
expect.arrayContaining(['/scheduled-messages', '/messages/s1']),
|
||||
)
|
||||
expect(store.selected?.pendingSend).toBeNull()
|
||||
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(store.scheduledCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('보내기 취소를 기다리는 메일(pendingSend undo) — 스팸 신고 없음 · 삭제는 확인 뒤 취소하고 휴지통으로 (리뷰 #1)', () => {
|
||||
const UNDO: MessageDetail = {
|
||||
...SCHEDULED,
|
||||
id: 'u1',
|
||||
subject: '방금 보낸 메일',
|
||||
pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' },
|
||||
}
|
||||
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
|
||||
|
||||
it('도구 줄에 스팸 신고가 없다 — 답장 · 전달은 있고 예약 안내 줄은 없다', async () => {
|
||||
const { wrapper } = await mountWith({ message: UNDO, source: { kind: 'mailbox', mailboxId: 'sent' } })
|
||||
|
||||
expect(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual([
|
||||
'답장',
|
||||
'전체 답장',
|
||||
'전달',
|
||||
'삭제',
|
||||
])
|
||||
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('[삭제] — 확인 창(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 · "보내지 않고 휴지통으로 옮겼습니다."', async () => {
|
||||
const { wrapper, router } = await mountWith({
|
||||
message: UNDO,
|
||||
source: { kind: 'mailbox', mailboxId: 'sent' },
|
||||
extra: (request) => (request.method === 'DELETE' ? ok({ draftId: 'u1' }) : undefined),
|
||||
})
|
||||
|
||||
await wrapper.get('[data-reader-delete]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#reader-scheduled-delete-title').text()).toBe('보내지 않은 메일을 삭제할까요?')
|
||||
expect(wrapper.get('#reader-scheduled-delete-message').text()).toBe('보내지 않고 휴지통으로 옮깁니다.')
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-scheduled-delete-cancel]').element)
|
||||
// 그냥 옮기지 않는다 — 옮기면 엔진은 그 시각에 그대로 보낸다
|
||||
expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
|
||||
|
||||
await wrapper.get('[data-scheduled-delete-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(cancels().map((request) => request.url)).toEqual(['/scheduled-messages/u1'])
|
||||
expect(requests.find((request) => request.method === 'PATCH' && request.url === '/messages/u1')?.data).toEqual({
|
||||
mailboxId: 'trash',
|
||||
})
|
||||
expect(toastMessages()).toEqual(['보내지 않고 휴지통으로 옮겼습니다.'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/m2')
|
||||
})
|
||||
|
||||
it('확인 창을 연 채 엔진이 보냈으면(붙잡은 발송이 풀림) 창을 닫는다 — 다른 확인(영구 삭제 등)으로 바뀌지 않는다', async () => {
|
||||
const { wrapper, store } = await mountWith({ message: UNDO, source: { kind: 'mailbox', mailboxId: 'sent' } })
|
||||
|
||||
await wrapper.get('[data-reader-delete]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('dialog[open]').exists()).toBe(true)
|
||||
|
||||
store.selected = { ...UNDO, pendingSend: null }
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('dialog[open]').exists()).toBe(false)
|
||||
expect(requests.some((request) => request.method === 'DELETE' || request.method === 'POST')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('보낸 사람 차단 — ⋯ 「보낸 사람 차단」(빨간 글) · 확인 창 · 스팸메일함의 회색 줄', () => {
|
||||
async function openBlockDialog(wrapper: Awaited<ReturnType<typeof mountWith>>['wrapper']) {
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const item = wrapper.findAll('[role="menuitem"]').at(-1)
|
||||
expect(item?.text()).toBe('보낸 사람 차단')
|
||||
expect(item?.classes()).toContain('menu__item--danger')
|
||||
await item?.trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.get('[data-block-dialog]')
|
||||
}
|
||||
|
||||
it('확인 창 — 「이 주소만」 · 옮기기 켜짐, [차단](처음 초점)이면 막고 그 메일을 스팸메일함으로 · 알림 [되돌리기]는 풀고 받은편지함으로', async () => {
|
||||
const { wrapper, router, store } = await mountWith()
|
||||
|
||||
const dialog = await openBlockDialog(wrapper)
|
||||
expect(wrapper.get('#block-dialog-title').text()).toBe('보낸 사람 차단')
|
||||
expect((dialog.get('[data-block-scope="address"]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(dialog.text()).toContain('news@cloudbrief.example')
|
||||
expect(dialog.text()).toContain('@cloudbrief.example 에서 오는 메일 모두')
|
||||
expect((dialog.get('[data-block-move]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-block-confirm]').element)
|
||||
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.find((request) => request.method === 'POST' && request.url === '/mail-blocks')?.data).toEqual({
|
||||
address: 'news@cloudbrief.example',
|
||||
})
|
||||
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({
|
||||
ids: ['m1'],
|
||||
mailboxId: 'junk',
|
||||
isJunk: true,
|
||||
})
|
||||
// 옮겼으니 삭제처럼 다음 메일을 연다
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
expect(toastMessages()).toEqual(['‘news@cloudbrief.example’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다.'])
|
||||
|
||||
runToastAction()
|
||||
await flushPromises()
|
||||
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/mail-blocks/news%40cloudbrief.example')).toBe(true)
|
||||
expect(requests.filter((request) => request.method === 'PATCH').at(-1)?.data).toEqual({
|
||||
ids: ['m1'],
|
||||
mailboxId: 'inbox',
|
||||
isJunk: false,
|
||||
})
|
||||
expect(toastMessages()).toContain('‘news@cloudbrief.example’ 차단을 풀고 메일을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('막는 사이 다른 메일함으로 옮겨 갔으면 옛 목록의 다음 메일로 끌고 가지 않는다 — 옮기기 · 알림은 그대로 (리뷰 #4)', async () => {
|
||||
const blocking = deferred()
|
||||
const { wrapper, router, store } = await mountWith({
|
||||
extra: (request) =>
|
||||
request.method === 'POST' && request.url === '/mail-blocks' ? (blocking.promise as unknown as MockResult) : undefined,
|
||||
})
|
||||
|
||||
await openBlockDialog(wrapper)
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
// 기다리는 사이 보낸편지함의 메일을 연다
|
||||
store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
|
||||
store.messages = [{ ...SCHEDULED, id: 'x1', pendingSend: null }]
|
||||
store.selectedId = 'x1'
|
||||
await router.push('/mail/sent/x1')
|
||||
blocking.resolve(ok({ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T06:00:00.000Z' }, 201))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/x1')
|
||||
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({ ids: ['m1'], mailboxId: 'junk', isJunk: true })
|
||||
expect(toastMessages()).toEqual(['‘news@cloudbrief.example’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다.'])
|
||||
})
|
||||
|
||||
it('「이 도메인 전체」를 고르고 옮기기를 끄면 @도메인을 막고 메일은 그대로 둔다', async () => {
|
||||
const { wrapper, router } = await mountWith()
|
||||
|
||||
const dialog = await openBlockDialog(wrapper)
|
||||
await dialog.get('[data-block-scope="domain"]').setValue(true)
|
||||
await dialog.get('[data-block-move]').setValue(false)
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.find((request) => request.method === 'POST')?.data).toEqual({ address: '@cloudbrief.example' })
|
||||
expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
expect(toastMessages()).toEqual(['‘@cloudbrief.example’을(를) 차단했습니다.'])
|
||||
})
|
||||
|
||||
it('여러 사람이 쓰는 메일 서비스(gmail.com 등)는 「이 도메인 전체」를 끄고 그 아래에 까닭', async () => {
|
||||
const { wrapper } = await mountWith({ message: { ...NEWSLETTER, from: { name: null, email: 'spammer@gmail.com' } } })
|
||||
|
||||
const dialog = await openBlockDialog(wrapper)
|
||||
const domain = dialog.get('[data-block-scope="domain"]')
|
||||
expect((domain.element as HTMLInputElement).disabled).toBe(true)
|
||||
expect(domain.attributes('aria-describedby')).toBe('block-dialog-domain-note')
|
||||
expect(wrapper.get('#block-dialog-domain-note').text()).toBe('여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.')
|
||||
})
|
||||
|
||||
it('이미 막은 보낸 사람(도메인으로 막음 포함)이면 「보낸 사람 차단 풀기」 — 바로 풀고 [되돌리기]는 다시 막는다', async () => {
|
||||
const { wrapper } = await mountWith({ blocks: [{ address: '@cloudbrief.example', blockedAt: '2026-10-21T00:00:00Z' }] })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const item = wrapper.findAll('[role="menuitem"]').at(-1)
|
||||
expect(item?.text()).toBe('보낸 사람 차단 풀기')
|
||||
expect(item?.classes()).not.toContain('menu__item--danger')
|
||||
await item?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/mail-blocks/%40cloudbrief.example')).toBe(true)
|
||||
expect(toastMessages()).toEqual(['‘@cloudbrief.example’ 차단을 풀었습니다.'])
|
||||
runToastAction()
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.method === 'POST').map((request) => request.data)).toEqual([
|
||||
{ address: '@cloudbrief.example' },
|
||||
])
|
||||
})
|
||||
|
||||
it('500개를 다 막았으면 항목을 끄고 까닭을 이름 아래에 — 골라도 창을 열지 않는다', async () => {
|
||||
const blocks = Array.from({ length: 500 }, (_, index) => ({ address: `s${index}@spam.example`, blockedAt: '2026-10-21T00:00:00Z' }))
|
||||
const { wrapper } = await mountWith({ blocks })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const item = wrapper.findAll('[role="menuitem"]').at(-1)
|
||||
expect(item?.attributes('aria-disabled')).toBe('true')
|
||||
expect(item?.get('.menu__note').text()).toBe('차단한 주소는 500개까지 둘 수 있습니다. 쓰지 않는 차단을 풀어 주세요.')
|
||||
await item?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('dialog[open] [data-block-dialog]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('막을 수 있는 보낸 사람이면 ⋯ 를 열 때 차단 목록을 알림 없이 받아 본다 — 내 메일(예약한 메일)에는 차단 항목이 없다', async () => {
|
||||
const external = await mountWith()
|
||||
await external.wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.url === '/mail-blocks')).toHaveLength(1)
|
||||
external.wrapper.unmount()
|
||||
|
||||
const own = await mountWith({ message: SCHEDULED, source: { kind: 'scheduled' } })
|
||||
await own.wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(own.wrapper.findAll('[role="menuitem"]').map((item) => item.text())).not.toContain('보낸 사람 차단')
|
||||
expect(requests.some((request) => request.url === '/mail-blocks')).toBe(false)
|
||||
})
|
||||
|
||||
it('스팸메일함에서 차단한 보낸 사람의 메일 — 회색 줄 "차단한 보낸 사람의 메일입니다." + [차단 풀기]', async () => {
|
||||
const { wrapper } = await mountWith({
|
||||
message: { ...NEWSLETTER, mailboxIds: ['junk'] },
|
||||
blocks: [{ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T00:00:00Z' }],
|
||||
})
|
||||
const notice = wrapper.get('[data-notice="blockedSender"]')
|
||||
expect(notice.text()).toContain('차단한 보낸 사람의 메일입니다.')
|
||||
|
||||
await notice.get('[data-sender-unblock]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/mail-blocks/news%40cloudbrief.example')).toBe(true)
|
||||
expect(wrapper.find('[data-notice="blockedSender"]').exists()).toBe(false)
|
||||
expect(toastMessages()).toEqual(['‘news@cloudbrief.example’ 차단을 풀었습니다.'])
|
||||
})
|
||||
|
||||
it('받은편지함의 메일이면 차단했어도 회색 줄이 없다', async () => {
|
||||
const { wrapper } = await mountWith({ blocks: [{ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T00:00:00Z' }] })
|
||||
|
||||
expect(wrapper.find('[data-notice="blockedSender"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,11 +3,16 @@
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 보낸 사람을 주소록에 추가(8단계) · 이런 메일로 필터 만들기)
|
||||
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길").
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 인쇄(9단계) · 원문 내려받기 ·
|
||||
// 보낸 사람을 주소록에 추가(8단계) · 이런 메일로 필터 만들기 · 보낸 사람 차단(9단계))
|
||||
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면").
|
||||
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
|
||||
// 예약한 메일은 답장 · 전체 답장 · 전달도 없고, 머리 아래 청록 안내 줄([고치기] [예약 취소])이 있다.
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
|
||||
import { computed } from 'vue'
|
||||
// 머리 아래 안내 줄(예약 · 차단한 보낸 사람 · 본문 한도 · 외부 이미지 [이미지 보기])은 MessageReaderNotices.
|
||||
// Ctrl+P(맥 ⌘P)는 브라우저 인쇄 대신 인쇄 화면을 새 탭에 연다(⋯ › 인쇄와 같다 — 본문을 눌러 초점이 본문 안에 있어도).
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 · 검색 결과 · 예약됨)이다.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
@@ -18,14 +23,16 @@ import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
|
||||
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
|
||||
import MessageReaderNotices from '@/components/mail/MessageReaderNotices.vue'
|
||||
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMailPrint } from '@/composables/useMailPrint'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { listedAttachments } from '@/shared/utils/mail-attachment'
|
||||
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
|
||||
import { mailboxOfMessage } from '@/shared/utils/mail-list-row'
|
||||
import { isHeldMessage, isScheduledMessage } from '@/shared/utils/pending-send'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -42,8 +49,15 @@ withDefaults(defineProps<Props>(), {
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { backToSourceList, replaceWithMailbox } = useMailNavigation()
|
||||
const print = useMailPrint()
|
||||
|
||||
const bodyFrameRef = ref<InstanceType<typeof MailBodyFrame> | null>(null)
|
||||
|
||||
const message = computed(() => mailStore.selected)
|
||||
/** 예약한 메일 — 답장 · 전달이 없다 (9단계) */
|
||||
const scheduled = computed(() => isScheduledMessage(message.value))
|
||||
/** 아직 보내지 않은 메일(보내는 중 · 예약) — 스팸 신고(옮기기)가 없다 */
|
||||
const held = computed(() => isHeldMessage(message.value))
|
||||
/** 검색 결과에서 연 메일의 메일함 이름 — 메일함 목록에서 열었으면 null */
|
||||
const searchMailboxName = computed(() => {
|
||||
if (!mailStore.search || !message.value) return null
|
||||
@@ -54,8 +68,6 @@ const fromEmail = computed(() => message.value?.from?.email ?? '')
|
||||
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
|
||||
const subject = computed(() => message.value?.subject || '(제목 없음)')
|
||||
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
|
||||
/** 서버가 본문을 다 주지 못했을 때의 안내 — 서식을 비웠으면 먼저, 잘렸으면 다음 (계약 2절 "읽기 한도") */
|
||||
const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.value) : []))
|
||||
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
|
||||
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
|
||||
|
||||
@@ -68,6 +80,30 @@ function leaveToList() {
|
||||
const source = mailStore.listSource
|
||||
void (source ? backToSourceList(source) : replaceWithMailbox(null))
|
||||
}
|
||||
|
||||
/** Ctrl+P(맥 ⌘P) — 열린 메일이 있으면 브라우저 인쇄 대신 인쇄 화면을 연다 */
|
||||
function onPrintKey(event: KeyboardEvent) {
|
||||
const current = message.value
|
||||
if (!current || mailStore.selectedStatus !== 'ready' || !print.isPrintShortcut(event)) return
|
||||
event.preventDefault()
|
||||
print.openPrint(current)
|
||||
}
|
||||
|
||||
/** 안내 줄의 단추가 줄과 함께 사라졌다 — 초점을 잃었으면 본문으로 */
|
||||
function onNoticeSettled() {
|
||||
const active = document.activeElement
|
||||
if (active !== null && active !== document.body) return
|
||||
const frame: unknown = bodyFrameRef.value?.$el
|
||||
if (frame instanceof HTMLElement) frame.focus()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onPrintKey)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onPrintKey)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -87,34 +123,36 @@ function leaveToList() {
|
||||
>
|
||||
<slot name="toolbar-start" />
|
||||
<template v-if="message">
|
||||
<MailToolButton
|
||||
icon="reply"
|
||||
label="답장"
|
||||
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="reply-all"
|
||||
label="전체 답장"
|
||||
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="forward"
|
||||
label="전달"
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MessageSpamButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<template v-if="!scheduled">
|
||||
<MailToolButton
|
||||
icon="reply"
|
||||
label="답장"
|
||||
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="reply-all"
|
||||
label="전체 답장"
|
||||
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="forward"
|
||||
label="전달"
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MessageSpamButton
|
||||
v-if="!held"
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
</template>
|
||||
<MessageDeleteButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<MessageMoreMenu
|
||||
:message-id="message.id"
|
||||
:sender-email="message.from?.email ?? null"
|
||||
:sender-name="message.from?.name ?? null"
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -211,38 +249,20 @@ function leaveToList() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 안내 — 본문이 다 보이는지가 먼저다 (생략 → 잘림), 그다음 외부 이미지 차단 -->
|
||||
<p
|
||||
v-for="notice in bodyNotices"
|
||||
:key="notice.kind"
|
||||
class="reader__notice"
|
||||
:data-notice="notice.kind"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
class="reader__notice-icon"
|
||||
/>
|
||||
<span>{{ notice.text }}</span>
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="message.hasBlockedImages"
|
||||
class="reader__notice"
|
||||
data-notice="blockedImages"
|
||||
>
|
||||
<AppIcon
|
||||
name="shield"
|
||||
:size="15"
|
||||
class="reader__notice-icon"
|
||||
/>
|
||||
<span>외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.</span>
|
||||
</p>
|
||||
<!-- 안내 줄 — 예약 · 차단한 보낸 사람 · 본문이 다 보이는지(생략 → 잘림) · 외부 이미지 차단 [이미지 보기] -->
|
||||
<MessageReaderNotices
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
@settled="onNoticeSettled"
|
||||
/>
|
||||
|
||||
<MailBodyFrame
|
||||
ref="bodyFrameRef"
|
||||
:html-body="message.htmlBody"
|
||||
:text-body="message.textBody"
|
||||
:images-shown="message.imagesShown === true"
|
||||
:title="`메일 본문: ${subject}`"
|
||||
@frame-keydown="onPrintKey"
|
||||
/>
|
||||
|
||||
<MessageAttachments
|
||||
@@ -413,21 +433,6 @@ function leaveToList() {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reader__notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.reader__notice-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* 툴바가 없을 때(분할 보기)는 툴바 높이만큼 비워 두어 불러온 뒤 화면이 튀지 않게 한다 */
|
||||
.reader__loading {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면 머리 아래의 안내 줄 (디자인 원본 MailReadTools · MailSendLater · README "9단계" · "읽기 화면의 본문 안내")
|
||||
// 차례: 예약한 메일(청록 — [고치기] [예약 취소]) → 차단한 보낸 사람의 메일(회색 — 스팸메일함에서만, [차단 풀기]) →
|
||||
// 본문이 다 보이는지(생략 → 잘림 — 계약 2절 "읽기 한도") → 외부 이미지 차단(노랑 — [이미지 보기]).
|
||||
// - [이미지 보기](36 — 밀도 예외 --size-notice-action): 그 메일만 `images=show` 로 다시 받아 본문만 바꾼다(스토어의 showImages — 늦은 응답은 버린다).
|
||||
// 보이면(imagesShown — 설정으로 처음부터 보인 것 포함) 이 줄은 없다. 다시 열면 설정대로다
|
||||
// - 예약 안내 줄의 단추는 붙잡은 발송 취소(useScheduledMessageActions) — 하는 동안은 두 단추를 함께 막는다(aria-disabled · aria-busy)
|
||||
// - 단추를 누른 뒤 줄이 사라지면 초점을 본문으로 돌려 달라고 알린다(settled — 읽기 화면이 옮긴다. [이미지 보기] · [차단 풀기] · [고치기] · [예약 취소])
|
||||
// 줄 안의 단추는 disabled 대신 aria-disabled — 초점이 그대로 남게 (누르면 아무것도 하지 않는다)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useScheduledMessageActions } from '@/composables/useScheduledMessageActions'
|
||||
import { useSenderBlock } from '@/composables/useSenderBlock'
|
||||
import { MAIL_IMAGES_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { formatScheduleTime } from '@/shared/utils/format'
|
||||
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
|
||||
import { messagePendingSend } from '@/shared/utils/pending-send'
|
||||
import { useMailBlockStore } from '@/stores/mail-block.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
message: MessageDetail
|
||||
/** split: 예약을 취소하면 다음 메일을 연다 · list: 목록으로 */
|
||||
layout: 'split' | 'list'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 누른 단추가 줄과 함께 사라졌다 — 초점을 옮겨 달라 */
|
||||
(e: 'settled'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const blockStore = useMailBlockStore()
|
||||
const scheduledActions = useScheduledMessageActions()
|
||||
const senderBlock = useSenderBlock()
|
||||
|
||||
/** 예약 줄 · 차단 줄의 단추가 하는 중 */
|
||||
const working = ref<'edit' | 'cancel' | 'unblock' | null>(null)
|
||||
|
||||
const pending = computed(() => messagePendingSend(props.message))
|
||||
const scheduledTime = computed(() =>
|
||||
pending.value?.kind === 'scheduled' ? formatScheduleTime(pending.value.sendAt) : null,
|
||||
)
|
||||
/** 스팸메일함에 든 메일의 보낸 사람이 차단되어 있으면 그 차단 */
|
||||
const senderBlockEntry = computed(() => {
|
||||
const junkId = mailStore.mailboxByRole('junk')?.id
|
||||
if (!junkId || !props.message.mailboxIds.includes(junkId)) return null
|
||||
return blockStore.blockOf(props.message.from?.email)
|
||||
})
|
||||
const bodyNotices = computed(() => mailBodyNotices(props.message))
|
||||
const imagesLoading = computed(() => mailStore.imagesLoadingId === props.message.id)
|
||||
|
||||
async function showImages() {
|
||||
if (imagesLoading.value) return
|
||||
if (await mailStore.showImages()) emit('settled')
|
||||
}
|
||||
|
||||
/** [고치기] · [예약 취소] — 취소했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다) */
|
||||
async function runScheduled(kind: 'edit' | 'cancel') {
|
||||
if (working.value) return
|
||||
working.value = kind
|
||||
let done = false
|
||||
try {
|
||||
done =
|
||||
kind === 'edit'
|
||||
? await scheduledActions.editScheduled(props.message.id)
|
||||
: await scheduledActions.cancelScheduled(props.message, props.layout)
|
||||
} finally {
|
||||
working.value = null
|
||||
}
|
||||
if (done) emit('settled')
|
||||
}
|
||||
|
||||
async function unblock() {
|
||||
const entry = senderBlockEntry.value
|
||||
if (!entry || working.value) return
|
||||
working.value = 'unblock'
|
||||
try {
|
||||
if (await senderBlock.unblock(entry.address)) emit('settled')
|
||||
} finally {
|
||||
working.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="scheduledTime"
|
||||
class="reader__notice reader__notice--brand"
|
||||
data-notice="scheduled"
|
||||
>
|
||||
<AppIcon
|
||||
name="clock"
|
||||
:size="15"
|
||||
class="reader__notice-icon"
|
||||
/>
|
||||
<span class="reader__notice-text reader__notice-text--strong">{{ SEND_LATER_TEXT.notice(scheduledTime) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="reader__notice-action"
|
||||
:aria-disabled="working ? 'true' : undefined"
|
||||
:aria-busy="working === 'edit' ? 'true' : undefined"
|
||||
data-scheduled-edit
|
||||
@click="runScheduled('edit')"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.edit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="reader__notice-action"
|
||||
:aria-disabled="working ? 'true' : undefined"
|
||||
:aria-busy="working === 'cancel' ? 'true' : undefined"
|
||||
data-scheduled-cancel
|
||||
@click="runScheduled('cancel')"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.cancelSchedule }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="senderBlockEntry"
|
||||
class="reader__notice reader__notice--muted"
|
||||
data-notice="blockedSender"
|
||||
>
|
||||
<AppIcon
|
||||
name="block"
|
||||
:size="15"
|
||||
class="reader__notice-icon"
|
||||
/>
|
||||
<span class="reader__notice-text">{{ MAIL_BLOCK_TEXT.senderNotice }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="reader__notice-action"
|
||||
:aria-disabled="working ? 'true' : undefined"
|
||||
:aria-busy="working === 'unblock' ? 'true' : undefined"
|
||||
data-sender-unblock
|
||||
@click="unblock"
|
||||
>
|
||||
{{ MAIL_BLOCK_TEXT.unblock }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 본문 안내 — 본문이 다 보이는지가 먼저다 (생략 → 잘림), 그다음 외부 이미지 차단 -->
|
||||
<p
|
||||
v-for="notice in bodyNotices"
|
||||
:key="notice.kind"
|
||||
class="reader__notice reader__notice--plain"
|
||||
:data-notice="notice.kind"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
class="reader__notice-icon"
|
||||
/>
|
||||
<span>{{ notice.text }}</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="message.hasBlockedImages && !message.imagesShown"
|
||||
class="reader__notice"
|
||||
data-notice="blockedImages"
|
||||
>
|
||||
<AppIcon
|
||||
name="shield"
|
||||
:size="15"
|
||||
class="reader__notice-icon"
|
||||
/>
|
||||
<span class="reader__notice-text">{{ MAIL_IMAGES_TEXT.blocked }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="reader__notice-action"
|
||||
:aria-disabled="imagesLoading ? 'true' : undefined"
|
||||
:aria-busy="imagesLoading ? 'true' : undefined"
|
||||
data-show-images
|
||||
@click="showImages"
|
||||
>
|
||||
{{ MAIL_IMAGES_TEXT.show }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 노란 안내 줄 (디자인 원본 MailReadTools — 바탕 #FFF7E6 · 글 #7A5200 · 12.5px · 위아래 8 · 왼쪽 12 · 오른쪽 8) */
|
||||
.reader__notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
margin: 0;
|
||||
padding: var(--space-8) var(--space-8) var(--space-8) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
font-size: var(--font-size-label);
|
||||
--reader-notice-button-border: var(--color-warning-border);
|
||||
--reader-notice-button-hover: var(--color-warning-hover);
|
||||
}
|
||||
|
||||
/* 단추 없는 본문 안내 — 지금까지의 모양 그대로 (위아래 10 · 좌우 12, 아이콘은 첫 줄에) */
|
||||
.reader__notice--plain {
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-10) var(--space-12);
|
||||
}
|
||||
|
||||
.reader__notice--plain .reader__notice-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* 예약한 메일 — 청록 (디자인 원본 MailSendLater: 바탕 #E3F2F3 · 글 #0A5F67 · 단추 테두리 #9ED6DB) */
|
||||
.reader__notice--brand {
|
||||
gap: var(--space-8);
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
--reader-notice-button-border: var(--color-on-brand-link);
|
||||
--reader-notice-button-hover: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
/* 차단한 보낸 사람 — 회색 (디자인 원본 MailReadTools: 바탕 #F2F4F7 · 글 #344054 · 단추 테두리 #D0D5DD) */
|
||||
.reader__notice--muted {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-strong);
|
||||
--reader-notice-button-border: var(--color-border-strong);
|
||||
--reader-notice-button-hover: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.reader__notice-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.reader__notice-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reader__notice-text--strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 줄 안의 흰 단추 36 — 밀도 예외 (--size-notice-action) */
|
||||
.reader__notice-action {
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--reader-notice-button-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reader__notice-action:hover:not([aria-disabled='true']) {
|
||||
background: var(--reader-notice-button-hover);
|
||||
}
|
||||
|
||||
.reader__notice-action[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -30,11 +30,12 @@ const available = computed(() => mailStore.mailboxByRole(inJunk.value ? 'inbox'
|
||||
|
||||
async function onClick() {
|
||||
if (working.value) return
|
||||
const target = props.message
|
||||
const source = mailStore.listSource
|
||||
working.value = true
|
||||
try {
|
||||
const outcome = await removal.moveSpam(props.message)
|
||||
await removal.leaveRemoved(outcome, source, props.layout)
|
||||
const outcome = await removal.moveSpam(target)
|
||||
await removal.leaveRemoved(outcome, source, props.layout, target.id)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
|
||||
@@ -258,6 +258,41 @@ describe('MessageTable (목록 보기)', () => {
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm3'])
|
||||
})
|
||||
|
||||
it('아직 보내지 않은 메일(보내는 중 · 예약 — 9단계)은 고를 수 없다 — 꺼진 상자(aria-disabled · 까닭 title), 누름 · Ctrl · Shift 도 · 머리 상자도 건너뛴다 (리뷰 #1)', async () => {
|
||||
const held = [
|
||||
summary('m1', { mailboxIds: ['sent'] }),
|
||||
summary('u1', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' } }),
|
||||
summary('s1', { mailboxIds: ['sent'], pendingSend: { kind: 'scheduled', sendAt: '2026-10-30T00:00:00.000Z' } }),
|
||||
summary('m2', { mailboxIds: ['sent'] }),
|
||||
]
|
||||
const { wrapper, router, store } = await mountTable({ mailbox: SENT, messages: held })
|
||||
const boxes = () => wrapper.findAll('tbody [role="checkbox"]')
|
||||
const rows = () => wrapper.findAll('tbody tr')
|
||||
|
||||
expect(boxes().map((box) => box.attributes('aria-disabled') ?? null)).toEqual([null, 'true', 'true', null])
|
||||
expect(boxes()[1]?.attributes('title')).toBe(
|
||||
'보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.',
|
||||
)
|
||||
expect(boxes()[0]?.attributes('title')).toBeUndefined()
|
||||
|
||||
await boxes()[1]?.trigger('click')
|
||||
await rows()[2]?.get('.mail-table__who').trigger('click', { ctrlKey: true })
|
||||
await flushPromises()
|
||||
expect(store.checkedCount).toBe(0)
|
||||
// 수정 글쇠 누름은 메일도 열지 않는다
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
|
||||
// 범위 · 모두 고르기는 붙잡은 메일을 건너뛴다
|
||||
await boxes()[0]?.trigger('click')
|
||||
await rows()[3]?.get('.mail-table__who').trigger('click', { shiftKey: true })
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2'])
|
||||
expect(wrapper.get('thead [role="checkbox"]').attributes('aria-checked')).toBe('true')
|
||||
await wrapper.get('thead [role="checkbox"]').trigger('click')
|
||||
expect(store.checkedCount).toBe(0)
|
||||
await wrapper.get('thead [role="checkbox"]').trigger('click')
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2'])
|
||||
})
|
||||
|
||||
it('Esc 는 목록 칸 안에서 고른 것을 푼다', async () => {
|
||||
const { wrapper, store } = await mountTable({ messages: three() })
|
||||
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
|
||||
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
|
||||
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
|
||||
// - 아직 보내지 않은 내 메일(9단계)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록) — 칸(84)보다 길면 줄이고 전체는 title 로.
|
||||
// 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다 (옮기거나 지워도 엔진은 그 시각에 보낸다)
|
||||
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
@@ -10,9 +12,11 @@ import type { RouteLocationRaw } from 'vue-router'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
|
||||
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
|
||||
import { isHeldMessage } from '@/shared/utils/pending-send'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
@@ -43,6 +47,10 @@ const who = computed(() => rowWho(props.message, { role: props.role, mixed: prop
|
||||
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
|
||||
const unread = computed(() => isRowUnread(props.message, props.role))
|
||||
const draft = computed(() => isDraftRole(props.role))
|
||||
/** 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
|
||||
const pendingLabel = computed(() => pendingSendLabel(props.message))
|
||||
/** 아직 보내지 않은 메일 — 고를 수 없다 */
|
||||
const held = computed(() => isHeldMessage(props.message))
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent) {
|
||||
@@ -50,7 +58,7 @@ function onRowClick(event: MouseEvent) {
|
||||
if (target?.closest('button')) return
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
event.preventDefault()
|
||||
emit('check', props.message.id, event.shiftKey)
|
||||
if (!held.value) emit('check', props.message.id, event.shiftKey)
|
||||
return
|
||||
}
|
||||
if (target?.closest('a')) return
|
||||
@@ -77,6 +85,8 @@ function onMouseDown(event: MouseEvent) {
|
||||
class="mail-table__check-button"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
:unavailable="held"
|
||||
:note="SEND_LATER_TEXT.heldNotSelectable"
|
||||
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
|
||||
/>
|
||||
</td>
|
||||
@@ -127,8 +137,13 @@ function onMouseDown(event: MouseEvent) {
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__date">
|
||||
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
|
||||
<td
|
||||
:class="['mail-table__date', { 'is-pending': pendingLabel }]"
|
||||
:title="pendingLabel ?? undefined"
|
||||
>
|
||||
<time :datetime="message.pendingSend?.sendAt ?? message.receivedAt">{{
|
||||
pendingLabel ?? formatMailTableDate(message.receivedAt)
|
||||
}}</time>
|
||||
</td>
|
||||
<td class="mail-table__size">
|
||||
{{ formatFileSize(message.size) }}
|
||||
@@ -239,4 +254,10 @@ function onMouseDown(event: MouseEvent) {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 아직 보내지 않은 내 메일 — 청록 굵게 (디자인 원본 MailSendLater) */
|
||||
.mail-table__row .mail-table__date.is-pending {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
// 예약됨 목록의 한 행 (디자인 원본 MailSendLater "예약됨 목록") — 누르면 읽기 화면(/mail/scheduled/<id>)에서 연다
|
||||
// - 분할 보기(split): 2줄 — 받는 사람(“받는 사람: 이도윤 외 2명”) · 보낼 시각(청록 굵게) / 제목 + 첨부 클립
|
||||
// - 목록 보기(list): 한 줄 세 칸 — 받는 사람 150 · 제목 · 보낼 시각 150(오른쪽 맞춤), 행 52
|
||||
// 보낼 시각은 응답의 pendingSend.sendAt — 없으면(보낸 직후 등) 받은 시각으로 대신한다. 고르는 칸 · 별은 없다
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { formatMailListTime, formatScheduleTime } from '@/shared/utils/format'
|
||||
import { rowWho } from '@/shared/utils/mail-list-row'
|
||||
import { messagePendingSend } from '@/shared/utils/pending-send'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
variant: 'split' | 'list'
|
||||
to: RouteLocationRaw
|
||||
/** 지금 열려 있는 메일 */
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selected: false,
|
||||
})
|
||||
|
||||
/** 받는 사람 — 보낸편지함과 같은 규칙에 "받는 사람: " 을 붙인다 (보드의 두 보기 모두) */
|
||||
const who = computed(() => rowWho(props.message, { role: 'sent', mixed: false, view: 'split' }))
|
||||
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
|
||||
const sendAt = computed(() => messagePendingSend(props.message)?.sendAt ?? null)
|
||||
const time = computed(() =>
|
||||
sendAt.value ? formatScheduleTime(sendAt.value) : formatMailListTime(props.message.receivedAt),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['scheduled-row', `scheduled-row--${variant}`, { 'is-selected': selected }]">
|
||||
<RouterLink
|
||||
class="scheduled-row__link"
|
||||
:to="to"
|
||||
:data-message-id="message.id"
|
||||
:aria-current="selected ? 'true' : undefined"
|
||||
>
|
||||
<span class="scheduled-row__who">{{ who.text }}</span>
|
||||
<span class="scheduled-row__subject">
|
||||
<span class="scheduled-row__subject-text">{{ subject }}</span>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="scheduled-row__clip"
|
||||
/>
|
||||
</span>
|
||||
<time
|
||||
class="scheduled-row__time"
|
||||
:datetime="sendAt ?? message.receivedAt"
|
||||
>{{ time }}</time>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scheduled-row {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.scheduled-row:hover {
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.scheduled-row.is-selected {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.scheduled-row__link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.scheduled-row__link:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.scheduled-row__link:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
.scheduled-row__who,
|
||||
.scheduled-row__subject-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scheduled-row__who {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.scheduled-row__subject {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.scheduled-row__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 보낼 시각 — 청록 굵게 (디자인 원본 MailSendLater #0A5F67 · 600) */
|
||||
.scheduled-row__time {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 분할 보기 — 2줄 행 50: 받는 사람 · 시각 / 제목 (분할 보기 메일 행과 같은 여백) */
|
||||
.scheduled-row--split .scheduled-row__link {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
'who time'
|
||||
'subject subject';
|
||||
align-content: center;
|
||||
gap: var(--space-1) var(--space-6);
|
||||
min-height: var(--size-list-row);
|
||||
padding: var(--space-6) var(--space-16);
|
||||
line-height: var(--line-height-heading);
|
||||
}
|
||||
|
||||
.scheduled-row--split .scheduled-row__who {
|
||||
grid-area: who;
|
||||
}
|
||||
|
||||
.scheduled-row--split .scheduled-row__time {
|
||||
grid-area: time;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.scheduled-row--split .scheduled-row__subject {
|
||||
grid-area: subject;
|
||||
}
|
||||
|
||||
/* 목록 보기 — 한 줄 세 칸 (디자인 원본 MailSendLater: 150 · 나머지 · 150, 사이 12, 행 52, 좌우 20) */
|
||||
.scheduled-row--list .scheduled-row__link {
|
||||
display: grid;
|
||||
grid-template-columns: 9.375rem minmax(0, 1fr) 9.375rem;
|
||||
align-items: center;
|
||||
gap: var(--table-gap);
|
||||
min-height: var(--size-header-min);
|
||||
padding: 0 var(--space-20);
|
||||
}
|
||||
|
||||
.scheduled-row--list .scheduled-row__time {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
// 예약됨 목록 (디자인 원본 MailSendLater "예약됨 목록" · README "9단계 › 「예약됨」") — /mail/scheduled
|
||||
// 받는 사람 · 제목 · 보낼 시각(청록 — "10월 30일 (금) 오전 9:00") 차례로, 보낼 시각이 빠른 것부터(서버 차례 그대로).
|
||||
// 고르는 칸 · 여러 통 처리 · 중요 표시는 없다 — 한 통씩 연다. 보낸 메일은 저절로 빠지고 보낸편지함에 남는다(새 메일 확인 · 실시간 알림이 맞춘다).
|
||||
// 분할 보기는 2줄 행(받는 사람 · 시각 / 제목), 목록 보기는 한 줄 세 칸(받는 사람 150 · 제목 · 보낼 시각 150 — 행 52).
|
||||
// 머리 · 상태(불러오는 중 · 못 불러옴 · 비었음) · 메일 더 보기 · 표 위치 기억은 목록 틀(MailListFrame)이 맡는다.
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import ScheduledMessageItem from '@/components/mail/ScheduledMessageItem.vue'
|
||||
import { messageLocationOf, SCHEDULED_LIST_SOURCE } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
variant: 'split' | 'list'
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailListFrame
|
||||
:class="['scheduled-list', `scheduled-list--${variant}`]"
|
||||
:variant="variant"
|
||||
title-id="scheduled-list-title"
|
||||
>
|
||||
<ul class="scheduled-list__items">
|
||||
<ScheduledMessageItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:variant="variant"
|
||||
:to="messageLocationOf(SCHEDULED_LIST_SOURCE, message.id)"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
/>
|
||||
</ul>
|
||||
</MailListFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 분할 보기 — 목록 칸 (MessageList 와 같은 폭 · 오른쪽 선) */
|
||||
.scheduled-list--split {
|
||||
flex: 1 1 24rem;
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 목록 보기 — 메일함 패널 오른쪽 전체 폭 (MessageTable 과 같다) */
|
||||
.scheduled-list--list {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.scheduled-list__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
|
||||
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
|
||||
// 머리 오른쪽에 단추를 둘 때는 actions 슬롯에 넣는다 — 좁으면 제목 아래로 내려간다 (디자인 원본 SettingsFilter 의 [새 필터])
|
||||
// 제목 바로 옆(같은 줄 · 글자 아래 선)에 작은 글을 둘 때는 title-aside 슬롯 (디자인 원본 SettingsSendRead 의 "3 / 500")
|
||||
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
|
||||
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
|
||||
interface Props {
|
||||
@@ -22,12 +23,15 @@ defineProps<Props>()
|
||||
<div class="settings-page__column">
|
||||
<div :class="['settings-page__head', { 'settings-page__head--actions': $slots.actions }]">
|
||||
<div class="settings-page__intro">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="settings-page__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<div class="settings-page__title-row">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="settings-page__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<slot name="title-aside" />
|
||||
</div>
|
||||
<p class="settings-page__description">
|
||||
{{ description }}
|
||||
</p>
|
||||
@@ -80,6 +84,13 @@ defineProps<Props>()
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.settings-page__title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-4) var(--space-10);
|
||||
}
|
||||
|
||||
.settings-page__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-lg);
|
||||
|
||||
Reference in New Issue
Block a user