generated from ttipo/project-base
feat(frontend): 쓰기 화면 나가기 확인·읽기 화면 본문 안내·내 메일 답장 받는 사람
- 쓰기 화면을 연 뒤 바꾼 것이 있으면 떠나기 전에 묻는다 — 앱 안 이동은 확인 대화상자, 탭 닫기·새로고침은 브라우저 확인창. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않고, 보내는 중에 떠나면 결과는 알림으로만 알린다(떠난 양식은 건드리지 않는다). 대화상자는 공용 ModalDialog 로 모았다 — 세션 만료 대화상자도 같은 것을 쓴다 - 본문 길이 초과는 MAIL_007 문구로 본문 아래에 알린다 (서버가 같은 코드를 돌려줘도 같다, 발송 키는 그대로) - 읽기 화면에 본문 안내(서식 없이 표시·표시할 수 없음·앞부분만 표시). 잘린 원문을 인용하면 생략 표시를 둔다. 인용할 원문은 읽기 화면과 같은 규칙으로 고른다 — 공백뿐인 HTML·그림만 남는 HTML 은 글자 본문으로 - 붙여 넣은 글·원문 인용에서 8px 미만 글자 크기와 알파 0.3 미만 글자 색을 버린다 (서버와 같은 규칙, 같은 표본 표 105행) - 내가 보낸 메일에 답장하면 받는 사람이 원래 받는 사람이다 (전에는 받는 사람 칸이 비었다) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
|
||||
// jsdom 에는 showModal 이 없다 — 대화상자는 open 속성만 붙였다 뗀다 (ModalDialog 의 대체 경로)
|
||||
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) wrapper.unmount()
|
||||
})
|
||||
|
||||
function mountDialog(open: boolean) {
|
||||
const wrapper = mount(ModalDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open, title: '보내지 않은 메일이 있습니다', message: '글', titleId: 't', messageId: 'm', role: 'alertdialog' },
|
||||
slots: {
|
||||
actions: () => [
|
||||
h('button', { id: 'stay', type: 'button', autofocus: true }, '계속 쓰기'),
|
||||
h('button', { id: 'leave', type: 'button' }, '나가기'),
|
||||
],
|
||||
},
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('ModalDialog', () => {
|
||||
it('제목 · 글을 aria 로 잇고, 열면 autofocus 단추로 · 닫으면 연 때의 자리로 초점을 옮긴다', async () => {
|
||||
const before = document.createElement('button')
|
||||
document.body.append(before)
|
||||
before.focus()
|
||||
const wrapper = mountDialog(false)
|
||||
const dialog = wrapper.get('dialog')
|
||||
|
||||
expect(dialog.attributes()).toMatchObject({ role: 'alertdialog', 'aria-labelledby': 't', 'aria-describedby': 'm' })
|
||||
expect(dialog.attributes('open')).toBeUndefined()
|
||||
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
expect(dialog.attributes('open')).toBeDefined()
|
||||
expect(document.activeElement?.id).toBe('stay')
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
expect(dialog.attributes('open')).toBeUndefined()
|
||||
expect(document.activeElement).toBe(before)
|
||||
before.remove()
|
||||
})
|
||||
|
||||
it('Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 닫을지는 쓰는 쪽이 정한다', async () => {
|
||||
const wrapper = mountDialog(true)
|
||||
await flushPromises()
|
||||
|
||||
const cancel = new Event('cancel', { cancelable: true })
|
||||
wrapper.get('dialog').element.dispatchEvent(cancel)
|
||||
|
||||
expect(cancel.defaultPrevented).toBe(true)
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
expect(wrapper.get('dialog').attributes('open')).toBeDefined()
|
||||
})
|
||||
|
||||
it('열려 있어야 하는데 브라우저가 스스로 닫으면(close) cancel 로 알린다 — 쓰는 쪽이 닫았을 때는 알리지 않는다', async () => {
|
||||
const wrapper = mountDialog(true)
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog').element
|
||||
|
||||
dialog.dispatchEvent(new Event('close'))
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
dialog.dispatchEvent(new Event('close'))
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('한 대화상자가 닫히며 다른 대화상자가 열리면 닫기가 먼저다 — 여는 쪽을 먼저 고쳐도 둘이 겹쳐 열리지 않는다', async () => {
|
||||
const first = ref<boolean>(true)
|
||||
// 여는 쪽(B)을 앞에 둔다 — 화면을 고치는 순서가 B 먼저여도 A 가 먼저 닫혀야 한다 (세션 만료 대화상자가 앞에 있는 앱과 같다)
|
||||
const Pair = defineComponent({
|
||||
setup() {
|
||||
return () => [
|
||||
h(ModalDialog, { open: !first.value, title: 'B', message: 'b', titleId: 'b-t', messageId: 'b-m', id: 'b' }),
|
||||
h(ModalDialog, { open: first.value, title: 'A', message: 'a', titleId: 'a-t', messageId: 'a-m', id: 'a' }),
|
||||
]
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Pair, { attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
const changes: string[] = []
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
const target = record.target as HTMLDialogElement
|
||||
changes.push(`${target.id}:${target.hasAttribute('open') ? 'open' : 'closed'}`)
|
||||
}
|
||||
})
|
||||
for (const dialog of wrapper.findAll('dialog')) {
|
||||
observer.observe(dialog.element, { attributes: true, attributeFilter: ['open'] })
|
||||
}
|
||||
|
||||
first.value = false
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
observer.disconnect()
|
||||
|
||||
expect(changes).toEqual(['a:closed', 'b:open'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · ComposeLeaveDialog(쓰기 화면 나가기 전 확인)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary 를 더한다)
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
/** 제목 · 글 문단의 id — aria-labelledby · aria-describedby 가 가리킨다 (화면에 하나뿐인 값) */
|
||||
titleId: string
|
||||
messageId: string
|
||||
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
|
||||
role?: 'alertdialog'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** Esc 로 닫으려 했다 — 브라우저가 막을 수 없게 스스로 닫았을 때도 같다 */
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
|
||||
let returnFocus: HTMLElement | null = null
|
||||
|
||||
function show() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog || dialog.open) return
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal()
|
||||
else dialog.setAttribute('open', '')
|
||||
// showModal 은 autofocus 단추로 초점을 옮긴다 — 없는 환경에서도 같게
|
||||
dialog.querySelector<HTMLElement>('[autofocus]')?.focus()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog?.open) return
|
||||
if (typeof dialog.close === 'function') dialog.close()
|
||||
else dialog.removeAttribute('open')
|
||||
// 초점이 대화상자 안에 남았거나 갈 곳을 잃었을 때만 돌려준다 (브라우저가 이미 돌려줬으면 그대로)
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body || dialog.contains(active)) returnFocus?.focus()
|
||||
returnFocus = null
|
||||
}
|
||||
|
||||
function onCancel(event: Event) {
|
||||
event.preventDefault()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
/** 열려 있어야 하는데 닫혔다 — 브라우저가 스스로 닫았다 (Esc 를 거듭 누르는 등). 쓰는 쪽의 상태와 맞춘다 */
|
||||
function onClose() {
|
||||
if (props.open) emit('cancel')
|
||||
}
|
||||
|
||||
// 닫기는 쓰는 쪽이 다시 그릴 때 바로, 열기는 화면을 다 고친 뒤에 (위 "닫기는 열기보다 먼저")
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) hide()
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) show()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.open) show()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="modal-dialog"
|
||||
:role="role"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="messageId"
|
||||
@cancel="onCancel"
|
||||
@close="onClose"
|
||||
>
|
||||
<h2
|
||||
:id="titleId"
|
||||
class="modal-dialog__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
:id="messageId"
|
||||
class="modal-dialog__message"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<div class="modal-dialog__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-dialog {
|
||||
width: min(22.5rem, calc(100vw - var(--space-48)));
|
||||
padding: var(--space-18) var(--space-18) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.modal-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.modal-dialog::backdrop {
|
||||
background: var(--color-backdrop);
|
||||
}
|
||||
|
||||
.modal-dialog__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-dialog__message {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 단추 줄 — 오른쪽 정렬 */
|
||||
.modal-dialog__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-8);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* 주 단추 — 브랜드색 (슬롯으로 받은 단추라 :slotted 로 꾸민다) */
|
||||
:slotted(.modal-dialog__action) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action:hover) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 보조 단추 — 바탕 없이 (쓰기 화면의 "취소"와 같은 모양) */
|
||||
:slotted(.modal-dialog__action--secondary) {
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--secondary:hover) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -2,9 +2,10 @@
|
||||
// 세션 만료(AUTH_002) 안내 — 디자인 원본 States "세션 만료"
|
||||
// useApi 가 로그인 상태의 401(갱신 실패)을 받으면 authStore.expireSession() 을 부르고, 이 대화상자가 뜬다.
|
||||
// 모달이라 뒤의 화면(이미 정리된 세션)을 더 조작할 수 없다. Esc 로 닫아도 로그인 화면으로 보낸다.
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
// 대화상자 틀(열기 · 닫기 · Esc · 모양)은 ModalDialog 가 맡는다.
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
@@ -12,125 +13,34 @@ const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 문구는 에러 코드 사전에서만 온다 */
|
||||
const message = getErrorMessage('AUTH_002')
|
||||
|
||||
function open() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog || dialog.open) return
|
||||
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal()
|
||||
else dialog.setAttribute('open', '')
|
||||
}
|
||||
|
||||
function close() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog?.open) return
|
||||
if (typeof dialog.close === 'function') dialog.close()
|
||||
else dialog.removeAttribute('open')
|
||||
}
|
||||
|
||||
async function goToLogin() {
|
||||
// 로그인 후 보던 화면으로 돌아오게 한다 (로그인이 필요한 화면일 때만)
|
||||
const redirect = route.meta.requiresAuth ? route.fullPath : undefined
|
||||
authStore.acknowledgeSessionExpired()
|
||||
await router.push({ name: 'login', query: redirect ? { redirect } : {} })
|
||||
}
|
||||
|
||||
function onCancel(event: Event) {
|
||||
event.preventDefault()
|
||||
void goToLogin()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.sessionExpired,
|
||||
(expired) => (expired ? open() : close()),
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (authStore.sessionExpired) open()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="session-dialog"
|
||||
aria-labelledby="session-expired-title"
|
||||
aria-describedby="session-expired-message"
|
||||
@cancel="onCancel"
|
||||
<ModalDialog
|
||||
:open="authStore.sessionExpired"
|
||||
title="다시 로그인해 주세요"
|
||||
:message="message"
|
||||
title-id="session-expired-title"
|
||||
message-id="session-expired-message"
|
||||
@cancel="goToLogin"
|
||||
>
|
||||
<h2
|
||||
id="session-expired-title"
|
||||
class="session-dialog__title"
|
||||
>
|
||||
다시 로그인해 주세요
|
||||
</h2>
|
||||
<p
|
||||
id="session-expired-message"
|
||||
class="session-dialog__message"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="session-dialog__action"
|
||||
@click="goToLogin"
|
||||
>
|
||||
로그인 화면으로
|
||||
</button>
|
||||
</dialog>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
@click="goToLogin"
|
||||
>
|
||||
로그인 화면으로
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.session-dialog {
|
||||
width: min(22.5rem, calc(100vw - var(--space-48)));
|
||||
padding: var(--space-18) var(--space-18) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.session-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.session-dialog::backdrop {
|
||||
background: var(--color-backdrop);
|
||||
}
|
||||
|
||||
.session-dialog__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-dialog__message {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.session-dialog__action {
|
||||
align-self: flex-end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
margin-top: var(--space-4);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-dialog__action:hover {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// 쓰기 화면 나가기 전 확인 — 디자인 README "나가기 전 확인" (세션 만료 대화상자와 같은 모양 · ModalDialog)
|
||||
// [계속 쓰기]가 처음 초점이고 Esc 도 같다. 쓰기 화면이 답을 받아 이동을 취소하거나(계속 쓰기) 그대로 보낸다(나가기).
|
||||
// 고정 id — 화면 검증 · 테스트가 쓴다: 대화상자 #compose-leave-dialog · 제목 #compose-leave-title · 글 #compose-leave-message ·
|
||||
// 단추 #compose-leave-stay(계속 쓰기) · #compose-leave-confirm(나가기)
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { COMPOSE_LEAVE_DIALOG } from '@/shared/constants/mail'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 계속 쓰기 (Esc 포함) — 이동을 취소한다 */
|
||||
(e: 'stay'): void
|
||||
/** 나가기 — 쓰던 내용을 버리고 이동한다 */
|
||||
(e: 'leave'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="compose-leave-dialog"
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="COMPOSE_LEAVE_DIALOG.title"
|
||||
:message="COMPOSE_LEAVE_DIALOG.message"
|
||||
title-id="compose-leave-title"
|
||||
message-id="compose-leave-message"
|
||||
@cancel="emit('stay')"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- 처음 초점 — 실수로 Enter · Space 를 눌러도 쓰던 내용이 남는다 -->
|
||||
<button
|
||||
id="compose-leave-stay"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
autofocus
|
||||
@click="emit('stay')"
|
||||
>
|
||||
{{ COMPOSE_LEAVE_DIALOG.stay }}
|
||||
</button>
|
||||
<button
|
||||
id="compose-leave-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('leave')"
|
||||
>
|
||||
{{ COMPOSE_LEAVE_DIALOG.leave }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
@@ -98,6 +99,68 @@ describe('MessageReader 도구 모음', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
async function mountWith(detail: MessageDetail) {
|
||||
const store = useMailStore()
|
||||
store.selected = detail
|
||||
store.selectedId = detail.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
return mount(MessageReader, { global: { plugins: [pinia, router] } })
|
||||
}
|
||||
|
||||
function notices(wrapper: Awaited<ReturnType<typeof mountWith>>) {
|
||||
return wrapper.findAll('.reader__notice').map((notice) => ({
|
||||
kind: notice.attributes('data-notice'),
|
||||
text: notice.text(),
|
||||
}))
|
||||
}
|
||||
|
||||
// 화면들이 같은 스토어(선택한 메일)를 보므로 한 번 띄울 때마다 바로 확인한다
|
||||
it('다 받은 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 응답도 같다', async () => {
|
||||
expect(notices(await mountWith(DETAIL))).toEqual([])
|
||||
expect(notices(await mountWith({ ...DETAIL, isHtmlBodyOmitted: false, isBodyTruncated: false }))).toEqual([])
|
||||
})
|
||||
|
||||
it('서식 본문을 비웠으면(isHtmlBodyOmitted) 글자 본문이 있을 때 · 없을 때 다른 문구', async () => {
|
||||
const withText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: '글자 본문' })
|
||||
expect(notices(withText)).toEqual([{ kind: 'htmlOmitted', text: MAIL_BODY_NOTICES.htmlOmitted }])
|
||||
|
||||
const withoutText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: null })
|
||||
expect(notices(withoutText)).toEqual([{ kind: 'unavailable', text: MAIL_BODY_NOTICES.unavailable }])
|
||||
})
|
||||
|
||||
it('생략 → 잘림 순서로, 외부 이미지 차단 안내 앞에 같은 모양으로 둔다', async () => {
|
||||
const wrapper = await mountWith({
|
||||
...DETAIL,
|
||||
htmlBody: '<p>앞부분</p>',
|
||||
isHtmlBodyOmitted: false,
|
||||
isBodyTruncated: true,
|
||||
hasBlockedImages: true,
|
||||
})
|
||||
|
||||
expect(notices(wrapper).map((notice) => notice.kind)).toEqual(['truncated', 'blockedImages'])
|
||||
expect(notices(wrapper)[0]?.text).toBe(MAIL_BODY_NOTICES.truncated)
|
||||
|
||||
const both = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '긴 글' })
|
||||
expect(notices(both).map((notice) => notice.kind)).toEqual(['htmlOmitted', 'truncated'])
|
||||
// 안내 줄 모양은 하나 — 아이콘 + 글
|
||||
for (const notice of both.findAll('.reader__notice')) {
|
||||
expect(notice.element.tagName).toBe('P')
|
||||
expect(notice.find('svg').exists()).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 불러오기 실패', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -42,6 +43,8 @@ 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) : []))
|
||||
|
||||
async function onDelete() {
|
||||
const current = message.value
|
||||
@@ -203,9 +206,25 @@ 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"
|
||||
|
||||
@@ -41,6 +41,8 @@ const draft = ref<string>('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
|
||||
/** 아직 토큰이 되지 않은 입력이 있다 — 쓰기 화면이 "쓰던 내용"으로 본다 (나가기 전 확인 · 칸을 벗어나지 않고 뒤로 가기 등) */
|
||||
const hasDraft = computed(() => draft.value.trim() !== '')
|
||||
|
||||
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
|
||||
function commit(text: string = draft.value) {
|
||||
@@ -90,7 +92,12 @@ function focus() {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ commit, focus })
|
||||
/** 아직 토큰이 되지 않은 입력을 버린다 — 쓰기 화면이 양식을 새로 채울 때 */
|
||||
function clearDraft() {
|
||||
draft.value = ''
|
||||
}
|
||||
|
||||
defineExpose({ commit, focus, clearDraft, hasDraft })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -45,7 +45,7 @@ interface Emits {
|
||||
(e: 'ready'): void
|
||||
/** 본문에 붙여 넣거나 끌어다 놓은 파일 — 첨부로 올린다 */
|
||||
(e: 'files', files: File[]): void
|
||||
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
|
||||
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) — 연 때와 다른지는 isChanged 로 묻는다 */
|
||||
(e: 'change'): void
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ const revision = ref<number>(0)
|
||||
let pastedImageCount = 0
|
||||
/** 편집 영역을 화면에 붙였다 — 그 전에는 편집기 화면(view)이 없다 */
|
||||
let mounted = false
|
||||
/**
|
||||
* 편집기를 다 연 때의 문서(답장 · 전달 초안은 맞춘 뒤) — 쓰기 화면이 "본문을 바꿨는가"를 이것과 비교한다 (나가기 전 확인).
|
||||
* settled: 같은 문서에 편집기가 스스로 끝 빈 줄을 붙인 모양 — 끝이 인용처럼 문단이 아니면 첫 거래(초점 옮기기 등) 때
|
||||
* 붙인다(StarterKit 의 TrailingNode). 사용자가 쓴 것이 아니므로 "바꿈"이 아니다.
|
||||
*/
|
||||
let opened: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
|
||||
|
||||
function initialContent(extensions: AnyExtension[]): Content {
|
||||
if (!props.initialHtml) return ''
|
||||
@@ -176,6 +182,13 @@ function getContentForSend(): MailEditorContent | null {
|
||||
return content
|
||||
}
|
||||
|
||||
/** 연 뒤에 본문을 바꿨는가 — 지웠다 다시 써 처음과 같아졌으면 바꾸지 않은 것이다 */
|
||||
function isChanged(): boolean {
|
||||
if (!opened) return false
|
||||
const { doc } = editor.state
|
||||
return !doc.eq(opened.doc) && !doc.eq(opened.settled)
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문에 초점을 준다 — 'start' · 'end' 면 커서를 맨 위(답장) · 맨 끝으로, 주지 않으면 커서 자리 그대로.
|
||||
* TipTap 의 focus 명령은 다음 화면 그리기 때 초점을 옮긴다 — 곧바로 쓰는 첫 글자가 사라지지 않게 바로 옮긴다.
|
||||
@@ -194,8 +207,11 @@ onMounted(() => {
|
||||
editor.mount(bodyRef.value)
|
||||
mounted = true
|
||||
}
|
||||
// 붙이면서 플러그인(실행 취소 기록 등)이 켜진다 — 툴바가 다시 읽게 한다
|
||||
// 붙이면서 플러그인(실행 취소 기록 · 끝 빈 줄 등)이 켜진다 — 툴바가 다시 읽게 하고, 연 때의 문서를 찍어 둔다.
|
||||
// settled 는 빈 거래 하나를 적용해 본 모양이다 (화면에 보내지 않는다 — 플러그인이 붙일 것만 미리 본다)
|
||||
revision.value += 1
|
||||
const { state } = editor
|
||||
opened = { doc: state.doc, settled: state.apply(state.tr).doc }
|
||||
emit('ready')
|
||||
})
|
||||
|
||||
@@ -211,7 +227,7 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({ getContent, getContentForSend, focus })
|
||||
defineExpose({ getContent, getContentForSend, focus, isChanged })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('fitDraftDoc — 답장·전달 초안 한도 (계약 2절)', () => {
|
||||
expect(fitted.child(0).textContent).toBe('내가 쓴 첫 줄')
|
||||
})
|
||||
|
||||
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 보낼 때 서버가 VAL_001 로 막는다)', () => {
|
||||
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 쓰기 화면이 보내지 않고, 보내도 서버가 MAIL_007 로 막는다)', () => {
|
||||
const doc = createDocument(lines(200), schema)
|
||||
|
||||
expect(fitDraftDoc(doc, schema, { text: 100, html: 1_000 })).toBe(doc)
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface DraftLimits {
|
||||
html: number
|
||||
}
|
||||
|
||||
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
|
||||
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 MAIL_007) */
|
||||
export const DRAFT_LIMITS: DraftLimits = { text: MAIL_BODY_MAX_LENGTH, html: MAIL_BODY_HTML_MAX_LENGTH }
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface MailEditorHandle {
|
||||
getContentForSend: () => MailEditorContent | null
|
||||
/** 본문으로 초점을 옮긴다 — 'start' 는 맨 위 첫 줄(답장), 'end' 는 맨 끝, 주지 않으면 커서 자리 그대로 */
|
||||
focus: (position?: 'start' | 'end') => void
|
||||
/**
|
||||
* 편집기를 연 뒤(답장 · 전달 초안을 맞춘 뒤) 사용자가 본문을 바꿨는가 — 쓰기 화면의 나가기 전 확인이 쓴다.
|
||||
* 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false
|
||||
*/
|
||||
isChanged: () => boolean
|
||||
}
|
||||
|
||||
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
|
||||
|
||||
@@ -79,6 +79,22 @@ describe('글자 서식 값 — 서버가 남기는 형식만 읽는다 (보이
|
||||
expect(outgoingHtml(doc)).not.toMatch(/font-size: 0|transparent/)
|
||||
})
|
||||
|
||||
it('보이지 않을 만큼 작은 글자(8px 미만) · 거의 투명한 글자 색(알파 0.3 미만)도 서식째 버린다 — 배경 색의 알파는 그대로', () => {
|
||||
const tiny = parse('<p><span style="font-size: 1px">작은 글</span></p>')
|
||||
const pale = parse('<p><span style="color: rgba(0, 0, 0, 0.1)">흐린 글</span></p>')
|
||||
const word = parse('<p><span style="font-size: 5.25pt">워드 7px</span></p>')
|
||||
const highlight = parse('<p><span style="background-color: rgba(0, 0, 0, 0)">배경</span></p>')
|
||||
|
||||
expect(firstTextStyle(tiny)?.fontSize ?? null).toBeNull()
|
||||
expect(firstTextStyle(pale)?.color ?? null).toBeNull()
|
||||
expect(firstTextStyle(word)?.fontSize ?? null).toBeNull()
|
||||
expect(outgoingHtml(tiny)).not.toContain('font-size: 1px')
|
||||
expect(outgoingHtml(pale)).not.toContain('rgba')
|
||||
expect(docToText(tiny)).toBe('작은 글')
|
||||
expect(firstTextStyle(highlight)?.backgroundColor).toBe('rgba(0, 0, 0, 0)')
|
||||
expect(firstTextStyle(parse('<p><span style="font-size: 6pt">a</span></p>'))?.fontSize).toBe('8px')
|
||||
})
|
||||
|
||||
it('pt · 긴 소수의 글자 크기는 px 로 맞춰 읽는다 (워드 10pt · 아웃룩 13.3333px), !important 는 뗀다', () => {
|
||||
expect(firstTextStyle(parse('<p><span style="font-size: 10pt">a</span></p>'))?.fontSize).toBe('13.33px')
|
||||
expect(firstTextStyle(parse('<p><span style="font-size: 13.3333px">a</span></p>'))?.fontSize).toBe('13.33px')
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('에러 코드 사전', () => {
|
||||
'MAIL_004', // 첨부 만료 — 쓰기 화면이 첨부를 "다시 올리기 필요"로 표시
|
||||
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
|
||||
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
|
||||
'MAIL_007', // 본문이 너무 김 — 쓰기 화면이 본문 아래 띠로 안내한다 (보내기 전 검사 · 서버 거절 모두)
|
||||
'FILE_001', // 첨부 형식 사전 검사
|
||||
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
|
||||
]
|
||||
|
||||
@@ -115,6 +115,15 @@ const showErrorUI = (code: ErrorCode, message: string, traceId?: string) => {
|
||||
useToastStore().show({ kind: 'error', message, traceId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림을 끄고(silent) 보낸 요청의 실패를 나중에 알린다 — 그 실패를 칸 안에서 안내하려던 화면이 이미 사라졌을 때
|
||||
* (예: 보내는 중에 쓰기 화면을 떠났다). 다른 오류 알림과 같은 길(showErrorUI — 같은 코드 중복 억제 · 세션 만료는 대화상자)이다.
|
||||
*/
|
||||
export function notifyApiError(error: ApiError): void {
|
||||
if (error.canceled) return
|
||||
showErrorUI(error.code, error.message, error.traceId)
|
||||
}
|
||||
|
||||
// 중앙 Axios 인스턴스
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
|
||||
@@ -171,6 +171,18 @@ describe('useAttachmentUploads', () => {
|
||||
stop()
|
||||
})
|
||||
|
||||
it('올린 항목을 돌려준다 — 쓰기 화면이 "처음 채운 첨부"로 기억한다 (나가기 전 확인)', () => {
|
||||
installMockApi(() => ok({}))
|
||||
const { uploads, stop } = withUploads()
|
||||
uploads.addFiles([file('회의록.pdf')])
|
||||
|
||||
const added = uploads.addExisting([CHECKLIST, { ...CHECKLIST, id: 'att3', name: '견적.pdf' }])
|
||||
|
||||
expect(added.map((item) => item.name)).toEqual(['체크리스트.xlsx', '견적.pdf'])
|
||||
expect(uploads.items.value.slice(1).map((item) => item.key)).toEqual(added.map((item) => item.key))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('허용 목록 밖의 원본 첨부는 실패(FILE_001)로 보이고 발송에서 빠진다', () => {
|
||||
installMockApi(() => ok({}))
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
@@ -162,8 +162,9 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
* 전달 — 원본 메일의 첨부를 목록에 미리 올려 둔다 (다시 올리지 않고 id 를 그대로 보낸다, 계약 2절 "전달").
|
||||
* 서버는 업로드와 같은 규칙으로 개수·확장자를, 발송 때 합계를 검사한다 — 걸리면 실패로 표시하고 발송에서 뺀다.
|
||||
* (파일당 한도는 업로드에만 있어 보지 않는다)
|
||||
* @returns 목록에 올린 항목 — 쓰기 화면이 "처음 채운 첨부"로 기억한다 (나가기 전 확인)
|
||||
*/
|
||||
function addExisting(attachments: readonly Attachment[]) {
|
||||
function addExisting(attachments: readonly Attachment[]): UploadItem[] {
|
||||
const running: AttachedSummary = { ...attached.value }
|
||||
const added = attachments.map((attachment): UploadItem => {
|
||||
const rejected = precheckAttachment(attachment, limitsFor('forward'), running)
|
||||
@@ -184,6 +185,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
}
|
||||
})
|
||||
items.value = [...items.value, ...added]
|
||||
return added
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { onBeforeUnmount, readonly, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
// 화면을 떠나기 전 확인 — 쓰던 내용을 잃는 이동을 묻는다 (디자인 README "나가기 전 확인")
|
||||
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 취소 · 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
|
||||
// 남기로 하면 이동을 취소한다 — 뒤로/앞으로였으면 라우터가 주소와 방문 기록을 되돌린다.
|
||||
// - 창 닫기 · 새로고침 · 다른 사이트로 가기: 지킬 것이 있는 동안만 beforeunload 를 걸어 브라우저 기본 확인창을 띄운다.
|
||||
// 화면 컴포넌트의 setup 에서 부른다 — 이동 가드(onBeforeRouteLeave · onBeforeRouteUpdate)는 RouterView 가 그린 화면에 걸린다.
|
||||
|
||||
export interface LeaveConfirmOptions {
|
||||
/**
|
||||
* 지금 앱 안에서 떠나면 잃는 것이 있다 — 이때만 묻는다. 반응형으로 읽는다.
|
||||
* 묻는 중에 false 가 되면(세션이 끝남 등) 대화상자를 닫고 그 이동은 취소한다 — 그 뒤의 이동은 다른 흐름(로그인 화면으로)이 한다.
|
||||
*/
|
||||
active: () => boolean
|
||||
/**
|
||||
* 창 닫기 · 새로고침 확인(beforeunload)을 걸 때 — 주지 않으면 active 와 같다. 반응형으로 읽는다.
|
||||
* 앱 안 이동은 묻지 않아도 창은 닫으면 안 되는 때가 있다 — 예: 보내는 중 (앱 안에서는 요청이 이어지고 결과를 토스트로 알리지만,
|
||||
* 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳도 없다)
|
||||
*/
|
||||
unloadActive?: () => boolean
|
||||
/**
|
||||
* 같은 경로 안의 이동(쿼리만 바뀜)도 이 화면을 떠나는 것인가 — 예: 답장 쓰기 → 새 메일 쓰기는 양식을 새로 채운다.
|
||||
* 주지 않으면 같은 경로 안의 이동은 묻지 않는다.
|
||||
*/
|
||||
leavesOnUpdate?: (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean
|
||||
}
|
||||
|
||||
export function useLeaveConfirm(options: LeaveConfirmOptions) {
|
||||
/** 대화상자를 띄우고 답을 기다리는 중 */
|
||||
const confirming = ref<boolean>(false)
|
||||
/** 기다리는 답 — 뒤로 가기를 거듭 눌러 이동이 겹쳐도 대화상자는 하나, 답도 하나다 */
|
||||
let pending: Promise<boolean> | null = null
|
||||
let resolvePending: ((leave: boolean) => void) | null = null
|
||||
|
||||
function ask(): Promise<boolean> {
|
||||
if (!pending) {
|
||||
pending = new Promise<boolean>((resolve) => {
|
||||
resolvePending = resolve
|
||||
})
|
||||
confirming.value = true
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
/** 답한다 — true: 떠난다 · false: 남는다 (이동을 취소한다) */
|
||||
function answer(leave: boolean) {
|
||||
const resolve = resolvePending
|
||||
pending = null
|
||||
resolvePending = null
|
||||
confirming.value = false
|
||||
resolve?.(leave)
|
||||
}
|
||||
|
||||
function confirmLeave(): true | Promise<boolean> {
|
||||
return options.active() ? ask() : true
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => confirmLeave())
|
||||
onBeforeRouteUpdate((to, from) => (options.leavesOnUpdate?.(to, from) ? confirmLeave() : true))
|
||||
|
||||
/** 창 닫기 · 새로고침 — 브라우저 기본 확인창 (문구는 브라우저가 정한다) */
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
event.preventDefault()
|
||||
// 옛 브라우저(Chrome · Edge 119 미만)는 returnValue 를 채워야 묻는다
|
||||
event.returnValue = true
|
||||
}
|
||||
|
||||
let listening = false
|
||||
function listenBeforeUnload(on: boolean) {
|
||||
if (on === listening) return
|
||||
listening = on
|
||||
if (on) window.addEventListener('beforeunload', onBeforeUnload)
|
||||
else window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
}
|
||||
|
||||
// 지킬 것이 있는 동안만 건다 — 늘 걸어 두면 일부 브라우저가 페이지를 뒤로 가기 캐시(bfcache)에 두지 않는다 (MDN 권고)
|
||||
watch(options.unloadActive ?? options.active, listenBeforeUnload, { immediate: true })
|
||||
|
||||
watch(options.active, (active) => {
|
||||
if (!active && pending) answer(false)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
listenBeforeUnload(false)
|
||||
// 다른 이동이 이겨 화면이 사라진다 — 기다리던 이동은 취소로 끝낸다
|
||||
if (pending) answer(false)
|
||||
})
|
||||
|
||||
return {
|
||||
confirming: readonly(confirming),
|
||||
/** 계속 쓰기 — 이동을 취소한다 */
|
||||
stay: () => answer(false),
|
||||
/** 나가기 — 이동을 그대로 보낸다 */
|
||||
leave: () => answer(true),
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
|
||||
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,11 @@ vi.mock('@/components/mail/editor/mail-editor-handle', async (importOriginal) =>
|
||||
})
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 10_000
|
||||
/**
|
||||
* 편집기 청크 · 편집기 자리를 기다리는 시간 — ComposePage.spec.ts 와 같은 값.
|
||||
* 두 겹 부하에서 이 파일의 테스트가 12.3초까지 걸렸다 (2026-10-27) — 테스트 한도(vitest.config.ts, 20초)보다는 짧게 둔다.
|
||||
*/
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 15_000
|
||||
|
||||
const ORIGINAL: MessageDetail = {
|
||||
id: 'm1',
|
||||
@@ -109,6 +113,7 @@ describe('ComposePage — 서식 편집기 청크', () => {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
provide: routeViewProvide(router),
|
||||
// 내려받기 오류는 Vue 가 앱 오류로도 알린다 — 테스트에서는 모아 둔다
|
||||
config: { errorHandler: (error) => loadErrors.push(error) },
|
||||
},
|
||||
@@ -117,11 +122,17 @@ describe('ComposePage — 서식 편집기 청크', () => {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤 */
|
||||
/**
|
||||
* 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤.
|
||||
* (답장은 원문을 받아 초안을 만들고 편집기 자리를 다시 그린 뒤에야 내려받기를 시작한다 — 편집기 기다림과 같은 시간을 준다)
|
||||
*/
|
||||
async function releaseEditor() {
|
||||
await vi.waitFor(() => {
|
||||
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
|
||||
})
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
loader.release?.()
|
||||
}
|
||||
|
||||
@@ -139,9 +150,13 @@ describe('ComposePage — 서식 편집기 청크', () => {
|
||||
loader.mode = 'fail-once'
|
||||
const view = await mountCompose('/mail/compose')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
|
||||
})
|
||||
// 청크 내려받기가 실패로 끝나고 오류 화면이 그려질 때까지 — 편집기 기다림과 같은 시간을 준다
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
const alert = view.get('.compose__editor [role="alert"]')
|
||||
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
|
||||
expect(view.get('.compose__send').attributes('disabled')).toBeDefined()
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { defineComponent, h, type FunctionalComponent } from 'vue'
|
||||
|
||||
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
|
||||
import {
|
||||
AUTH_CHANNEL_NAME,
|
||||
closeLogoutChannel,
|
||||
useLogoutSync,
|
||||
} from '@/composables/useLogoutBroadcast'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
@@ -13,6 +20,7 @@ import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
@@ -24,12 +32,17 @@ import {
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다 */
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 10_000
|
||||
/**
|
||||
* 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다.
|
||||
* 전체 실행 두 개를 함께 돌린 부하에서 편집기를 띄우는 테스트가 12.8초까지 걸렸다 (2026-10-27) — 15초로 두되,
|
||||
* 테스트 한도(vitest.config.ts, 20초)보다 짧게 둬 편집기가 끝내 뜨지 않으면 이 기다림이 먼저 알린다.
|
||||
*/
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 15_000
|
||||
let clock = Date.UTC(2026, 9, 26, 6, 0, 0)
|
||||
|
||||
const ORIGINAL: MessageDetail = {
|
||||
@@ -87,15 +100,20 @@ describe('ComposePage', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다 */
|
||||
async function mountCompose(path: string, handler: MockHandler, attach = false) {
|
||||
/**
|
||||
* attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다.
|
||||
* 화면은 RouterView 없이 띄운다 — 다른 경로로 가도 화면이 남아 결과를 볼 수 있다. 이동 가드는 지금 경로에 건다 (routeViewProvide)
|
||||
*/
|
||||
async function mountCompose(path: string, handler: MockHandler, attach = false, visited: readonly string[] = []) {
|
||||
const requests = installMockApi(handler)
|
||||
const router = createTestRouter()
|
||||
// 방문 기록 — 뒤로 가기를 확인할 때 쓰기 화면 앞에 둘 화면들
|
||||
for (const earlier of visited) await router.push(earlier)
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
attachTo: attach ? document.body : undefined,
|
||||
global: { plugins: [pinia, router] },
|
||||
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
@@ -625,7 +643,7 @@ describe('ComposePage', () => {
|
||||
await input.trigger('blur')
|
||||
}
|
||||
|
||||
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(VAL_001) — 본문으로 초점, 고치면 내린다', async () => {
|
||||
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(MAIL_007) — 본문으로 초점, 고치면 내린다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
|
||||
await addRecipient(wrapper)
|
||||
const editor = editorOf(wrapper.element)
|
||||
@@ -635,8 +653,9 @@ describe('ComposePage', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_007)
|
||||
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
|
||||
expect(wrapper.get('#compose-body').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('compose-body')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
@@ -646,6 +665,45 @@ describe('ComposePage', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('서버가 본문을 MAIL_007 로 거절하면(정화 한도 등) 토스트 없이 같은 띠 · 잘못된 입력 · 본문으로 초점 — 줄여 다시 보내면 같은 키', async () => {
|
||||
const outcomes: MockResult[] = [fail(400, 'MAIL_007'), ok({ id: 'sent-1' }, 201)]
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose',
|
||||
(request) =>
|
||||
request.method === 'POST' && request.url === '/messages'
|
||||
? (outcomes.shift() ?? ok({ id: 'sent-2' }, 201))
|
||||
: replyApi(request),
|
||||
true,
|
||||
)
|
||||
await addRecipient(wrapper)
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.setContent('<p>서식이 아주 복잡한 본문</p>')
|
||||
;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_007)
|
||||
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
|
||||
expect(wrapper.get('#compose-body').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('compose-body')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
// 줄이면 띠를 내리고, 다시 보내면 같은 키로 간다 (서버가 엔진에 넘기기 전에 거절했다)
|
||||
editor.commands.setContent('<p>짧게 줄인 본문</p>')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#compose-body-error').exists()).toBe(false)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const keys = requests
|
||||
.filter((request) => request.method === 'POST' && request.url === '/messages')
|
||||
.map((request) => request.config.headers.get('Idempotency-Key'))
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(keys[1]).toBe(keys[0])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('답장 원문이 길어도 맞춘 초안에 내 글을 쓰면 보낸다 — 남겨 둔 자리보다 길면 인용을 더 줄여(생략 표시) 보낸다', async () => {
|
||||
const longOriginal = { ...ORIGINAL, textBody: `${'긴 원문 줄입니다. '.repeat(10)}\n`.repeat(1_000) }
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', (request) =>
|
||||
@@ -662,7 +720,7 @@ describe('ComposePage', () => {
|
||||
expect(payload?.bodyHtml.length).toBeLessThanOrEqual(MAIL_BODY_HTML_MAX_LENGTH)
|
||||
expect(payload?.body.startsWith(mine.trimEnd())).toBe(true)
|
||||
expect(payload?.body.endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
}, LONG_DRAFT_TIMEOUT_MS)
|
||||
|
||||
it('제목이 255자를 넘으면(붙여 넣기 등) 보내지 않고 제목 아래에 사전 문구 — 제목으로 초점', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
|
||||
@@ -847,4 +905,505 @@ describe('ComposePage', () => {
|
||||
expect(new Set([replyKey, newMailKey, reopenedKey]).size).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('나가기 전 확인 (디자인 README "나가기 전 확인")', () => {
|
||||
const DIALOG = '#compose-leave-dialog'
|
||||
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
const forwardApi: MockHandler = (request) =>
|
||||
request.method === 'GET' && request.url === '/messages/m1'
|
||||
? ok({ ...ORIGINAL, attachments: [CHECKLIST] })
|
||||
: replyApi(request)
|
||||
|
||||
/** 이 탭에서 useLogoutSync 를 부르는 최소 화면 (App.vue 대신) */
|
||||
const LogoutSyncHost = defineComponent({
|
||||
setup() {
|
||||
useLogoutSync()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeLogoutChannel()
|
||||
FakeBroadcastChannel.closeAll()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function dialogOpen(wrapper: VueWrapper): boolean {
|
||||
return wrapper.get(DIALOG).attributes('open') !== undefined
|
||||
}
|
||||
|
||||
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
|
||||
function blocksUnload(): boolean {
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
async function writeSubject(wrapper: VueWrapper, text: string) {
|
||||
await wrapper.get('#compose-subject').setValue(text)
|
||||
}
|
||||
|
||||
function choose(file: File, wrapper: VueWrapper) {
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [file], configurable: true })
|
||||
return picker.trigger('change')
|
||||
}
|
||||
|
||||
it('손대지 않은 답장 초안은 바꾼 것이 아니다 — 편집기가 인용 뒤에 끝 빈 줄을 붙여도 묻지 않고 떠난다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
await flushPromises()
|
||||
// 초점을 옮기는 첫 거래에 편집기가 인용(맨 끝 블록) 뒤에 빈 문단을 붙였다
|
||||
expect(editorOf(wrapper.element).state.doc.lastChild?.type.name).toBe('paragraph')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('손대지 않은 전달 초안(원본 첨부 포함)도 묻지 않는다 — 원본 첨부를 빼면 바꾼 것이다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?forward=m1', forwardApi)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
await wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]').trigger('click')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
void router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
})
|
||||
|
||||
it('바꾸면 앱 안 이동 전에 묻는다 — alertdialog · 처음 초점은 계속 쓰기, 계속 쓰기는 이동을 취소하고 초점을 돌려준다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, true)
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
const subjectInput = wrapper.get('#compose-subject').element as HTMLInputElement
|
||||
subjectInput.focus()
|
||||
|
||||
const navigation = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
|
||||
const dialog = wrapper.get(DIALOG)
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
expect(dialog.attributes('role')).toBe('alertdialog')
|
||||
expect(dialog.attributes('aria-labelledby')).toBe('compose-leave-title')
|
||||
expect(dialog.attributes('aria-describedby')).toBe('compose-leave-message')
|
||||
expect(wrapper.get('#compose-leave-title').text()).toBe('보내지 않은 메일이 있습니다')
|
||||
expect(wrapper.get('#compose-leave-message').text()).toBe('이 화면을 나가면 쓰던 내용이 사라집니다.')
|
||||
expect(wrapper.get('#compose-leave-stay').text()).toBe('계속 쓰기')
|
||||
expect(wrapper.get('#compose-leave-confirm').text()).toBe('나가기')
|
||||
expect(document.activeElement?.id).toBe('compose-leave-stay')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
|
||||
await wrapper.get('#compose-leave-stay').trigger('click')
|
||||
await navigation
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
expect(subjectInput.value).toBe('일정 공유')
|
||||
expect(document.activeElement).toBe(subjectInput)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('쓰기 화면의 닫기 ✕ · 취소도 묻는다 — 나가기를 고르면 쓰던 내용을 버리고 보던 화면으로 간다', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('.compose__cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-stay').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
|
||||
await wrapper.get('.compose__close').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('Esc 는 계속 쓰기와 같다 — 브라우저가 닫지 않게 막고 이동을 취소한다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
const navigation = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
|
||||
const cancel = new Event('cancel', { cancelable: true })
|
||||
wrapper.get(DIALOG).element.dispatchEvent(cancel)
|
||||
await navigation
|
||||
await flushPromises()
|
||||
|
||||
expect(cancel.defaultPrevented).toBe(true)
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
})
|
||||
|
||||
it('뒤로 가기를 취소하면 주소와 방문 기록이 그대로다 — 다시 뒤로 가서 나가기를 고르면 앞 화면으로', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, false, ['/mail/inbox'])
|
||||
await writeSubject(wrapper, '초안')
|
||||
|
||||
router.back()
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-stay').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
expect(router.options.history.location).toBe('/mail/compose')
|
||||
|
||||
router.back()
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(router.options.history.location).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('묻는 중에 또 이동하면 대화상자는 하나다 — 나가기를 고르면 마지막 이동으로 간다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
|
||||
const first = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
const second = router.push('/mail/sent')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('dialog[open]')).toHaveLength(1)
|
||||
await wrapper.get('#compose-leave-confirm').trigger('click')
|
||||
await Promise.all([first, second])
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('같은 쓰기 화면 안에서도 쓰기 종류가 바뀌면(답장 → 새 메일) 묻는다 — 계속 쓰면 답장 그대로', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
|
||||
await writeSubject(wrapper, 'Re: 고친 제목')
|
||||
|
||||
const navigation = router.push('/mail/compose')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-stay').trigger('click')
|
||||
await navigation
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?replyTo=m1')
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('Re: 고친 제목')
|
||||
})
|
||||
|
||||
it('받는 사람 칸에 쓰던 글 · 본문 · 첨부(올리는 중 포함)도 바꿈이다 — 지우거나 되돌리면 묻지 않는다', async () => {
|
||||
// 올리기는 끝내지 않고 붙잡아 둔다 — "올리는 중"인 첨부도 바꿈이다
|
||||
const upload: { finish: ((result: MockResult) => void) | null } = { finish: null }
|
||||
const { wrapper } = await mountCompose('/mail/compose', (request) =>
|
||||
request.method === 'POST' && request.url === '/mail-attachments'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
upload.finish = resolve
|
||||
})
|
||||
: replyApi(request),
|
||||
)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
// 받는 사람 칸에 아직 토큰이 되지 않은 글
|
||||
await wrapper.get('#compose-to').setValue('kim@ara')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
await wrapper.get('#compose-to').setValue('')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
// 본문 — 쓰고 실행 취소하면 처음과 같다
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.insertContent('안녕하세요')
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(true)
|
||||
editor.commands.undo()
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
// 첨부 — 올리는 중이어도 바꿈, 빼면 처음과 같다
|
||||
await choose(new File(['x'], '회의록.pdf'), wrapper)
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.upload-item--uploading')).toHaveLength(1)
|
||||
expect(blocksUnload()).toBe(true)
|
||||
await wrapper.get('button[aria-label="회의록.pdf 첨부 취소"]').trigger('click')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
upload.finish?.(ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 1 }, 201))
|
||||
})
|
||||
|
||||
it('창 닫기 · 새로고침 확인(beforeunload)은 바꾼 동안만 건다 — 처음으로 돌아오거나 화면을 내리면 뗀다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', replyApi)
|
||||
|
||||
expect(blocksUnload()).toBe(false)
|
||||
await writeSubject(wrapper, '제목')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
await writeSubject(wrapper, '')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
await writeSubject(wrapper, '다시')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
})
|
||||
|
||||
it('보낸 뒤에는 묻지 않는다 — 보던 화면으로 바로 돌아가고 창 닫기도 막지 않는다', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox')
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('doyun.lee@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
})
|
||||
|
||||
describe('보내는 중에 떠나기 — 묻지 않고, 결과는 토스트로만 알린다 (떠난 양식은 건드리지 않는다)', () => {
|
||||
/** 라우터만 쓰는 빈 화면 — 쓰기 화면을 내린 뒤에도 앱(App.vue)처럼 라우터를 붙잡아 둔다 */
|
||||
const RouterHost: FunctionalComponent = () => h('div')
|
||||
|
||||
/** 첫 발송의 응답을 붙잡아 두는 서버 — 그다음 발송은 outcomes 를 차례로 돌려준다 */
|
||||
function heldSendApi(outcomes: MockResult[] = []) {
|
||||
const held: { release: ((result: MockResult) => void) | null } = { release: null }
|
||||
let first = true
|
||||
const handler: MockHandler = (request) => {
|
||||
if (request.method !== 'POST' || request.url !== '/messages') return replyApi(request)
|
||||
if (!first) return outcomes.shift() ?? ok({ id: 'sent-2' }, 201)
|
||||
first = false
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
held.release = resolve
|
||||
})
|
||||
}
|
||||
return { handler, held }
|
||||
}
|
||||
|
||||
function sentKeys(requests: MockRequest[]): unknown[] {
|
||||
return requests
|
||||
.filter((request) => request.method === 'POST' && request.url === '/messages')
|
||||
.map((request) => request.config.headers.get('Idempotency-Key'))
|
||||
}
|
||||
|
||||
function toastMessages(): string[] {
|
||||
return useToastStore().toasts.map((toast) => toast.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰기 화면만 내린다 — 앱(App.vue)처럼 라우터를 쓰는 화면 하나를 남겨 둔다.
|
||||
* (라우터를 쓰는 앱이 모두 내려가면 vue-router 가 지금 경로를 처음('/')으로 되돌려 "끌고 오지 않았다"를 볼 수 없다)
|
||||
*/
|
||||
function leaveScreen(wrapper: VueWrapper, router: ReturnType<typeof createTestRouter>) {
|
||||
mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
|
||||
wrapper.unmount()
|
||||
}
|
||||
|
||||
/** 고친 답장을 보내 놓는다 — 응답은 붙잡혀 있다 */
|
||||
async function startSending(wrapper: VueWrapper) {
|
||||
await writeSubject(wrapper, 'Re: 고친 제목')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.compose__send').text()).toBe('보내는 중…')
|
||||
}
|
||||
|
||||
it('보내는 중에는 앱 안 이동을 묻지 않는다 — 떠난 뒤 보내지면 성공 토스트만, 간 곳에서 끌고 오지 않는다 (창 닫기는 묻는다)', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
|
||||
const { handler, held } = heldSendApi()
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
|
||||
await startSending(wrapper)
|
||||
// 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳이 없다 — 창 닫기 · 새로고침은 보내는 중에도 묻는다
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
await router.push('/mail/sent')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
// RouterView 안이라면 화면이 내려간다
|
||||
leaveScreen(wrapper, router)
|
||||
|
||||
held.release?.(ok({ id: 'sent-1' }, 201))
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toEqual(['메일을 보냈습니다.'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['MAIL_002 (평소에는 받는 사람 칸 아래에 알린다)', fail(400, 'MAIL_002'), 'MAIL_002'],
|
||||
['MAIL_004 (평소에는 첨부 목록에 알린다)', fail(400, 'MAIL_004'), 'MAIL_004'],
|
||||
['MAIL_007 (평소에는 본문 아래 띠로 알린다)', fail(400, 'MAIL_007'), 'MAIL_007'],
|
||||
['MAIL_001 (평소에도 토스트)', fail(503, 'MAIL_001'), 'MAIL_001'],
|
||||
] as const)('떠난 뒤 보내기가 %s 로 끝나면 사전 문구 토스트 하나로 알린다', async (_label, failure, code) => {
|
||||
const { handler, held } = heldSendApi()
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
|
||||
await startSending(wrapper)
|
||||
await router.push('/mail/inbox')
|
||||
leaveScreen(wrapper, router)
|
||||
|
||||
held.release?.(failure)
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES[code]])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('보내는 중에 쓰기 종류가 바뀌면(답장 → 새 메일) 묻지 않고 새 양식을 연다 — 앞 보내기가 끝나도 새 양식과 그 키는 그대로', async () => {
|
||||
const { handler, held } = heldSendApi(['network', ok({ id: 'sent-3' }, 201)])
|
||||
const { wrapper, router, requests } = await mountCompose('/mail/compose?replyTo=m1', handler)
|
||||
await startSending(wrapper)
|
||||
|
||||
await router.push('/mail/compose')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(wrapper.get('h1').text()).toBe('새 메일')
|
||||
// 앞 요청에 묶이지 않는다 — 새 양식은 바로 보낼 수 있다
|
||||
expect(wrapper.get('.compose__send').text()).toBe('보내기')
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('junho.park@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
await writeSubject(wrapper, '새 메일 제목')
|
||||
// 새 양식의 첫 보내기 — 응답 없음(키를 그대로 둔다)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
// 앞 양식의 보내기가 이제 끝난다 — 알리기만 한다
|
||||
held.release?.(ok({ id: 'sent-1' }, 201))
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toContain('메일을 보냈습니다.')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
expect(chips(wrapper, 'compose-to')).toEqual(['junho.park@arasystem.it.kr'])
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('새 메일 제목')
|
||||
|
||||
// 앞 양식의 성공이 새 양식의 키를 바꾸지 않았다 — 다시 보내면 같은 키
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
const [oldKey, newKey, retryKey] = sentKeys(requests)
|
||||
expect(newKey).not.toBe(oldKey)
|
||||
expect(retryKey).toBe(newKey)
|
||||
})
|
||||
|
||||
it('쓰기 종류가 바뀐 뒤 앞 보내기가 MAIL_002 로 끝나면 — 새 양식에 받는 사람 오류를 띄우지 않고 토스트로 알린다', async () => {
|
||||
const { handler, held } = heldSendApi()
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
|
||||
await startSending(wrapper)
|
||||
await router.push('/mail/compose')
|
||||
await flushPromises()
|
||||
|
||||
held.release?.(fail(400, 'MAIL_002'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.MAIL_002])
|
||||
expect(wrapper.get('.compose__send').text()).toBe('보내기')
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['이 탭의 로그아웃', (store: ReturnType<typeof useAuthStore>) => store.clearAuth()],
|
||||
['세션 만료', (store: ReturnType<typeof useAuthStore>) => store.expireSession()],
|
||||
])('세션이 끝나면(%s) 묻지 않는다 — 로그인 화면으로 가는 이동이 이긴다', async (_label, end) => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
await writeSubject(wrapper, '쓰던 메일')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
end(useAuthStore())
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
await router.push({ name: 'login' })
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('다른 탭이 로그아웃하면 묻지 않고 로그인 화면으로 간다', async () => {
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
mountedWrappers.push(mount(LogoutSyncHost, { global: { plugins: [pinia, router] } }))
|
||||
await writeSubject(wrapper, '쓰던 메일')
|
||||
|
||||
new FakeBroadcastChannel(AUTH_CHANNEL_NAME).postMessage({ type: 'logout' })
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(useAuthStore().isLoggedIn).toBe(false)
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('묻는 중에 세션이 만료되면 이 대화상자를 닫고 그 이동은 취소한다 — 세션 만료 대화상자와 겹치지 않는다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
const sessionDialog = mount(SessionExpiredDialog, { global: { plugins: [pinia, router] } })
|
||||
mountedWrappers.push(sessionDialog)
|
||||
await writeSubject(wrapper, '쓰던 메일')
|
||||
const navigation = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await navigation
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(sessionDialog.get('dialog').attributes('open')).toBeDefined()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
|
||||
// 세션 만료 대화상자의 "로그인 화면으로"는 묻지 않고 간다 — 다시 로그인하면 쓰기 화면으로 돌아온다
|
||||
await sessionDialog.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/mail/compose')
|
||||
})
|
||||
|
||||
it('원문을 불러오는 동안 붙인 파일은 처음 채운 첨부가 아니다 — 바꿈으로 남는다', async () => {
|
||||
// 원문 응답을 붙잡아 둔다 — 그동안 양식은 막혀 있지만 첨부는 붙일 수 있다
|
||||
const original: { release: (() => void) | null } = { release: null }
|
||||
const requests = installMockApi((request) => {
|
||||
if (request.method === 'GET' && request.url === '/messages/m1') {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
original.release = () => resolve(ok({ ...ORIGINAL, attachments: [CHECKLIST] }))
|
||||
})
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/mail-attachments') {
|
||||
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 1 }, 201)
|
||||
}
|
||||
return replyApi(request)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/compose?forward=m1')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, { global: { plugins: [pinia, router], provide: routeViewProvide(router) } })
|
||||
mountedWrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
await choose(new File(['x'], '회의록.pdf'), wrapper)
|
||||
await flushPromises()
|
||||
original.release?.()
|
||||
await editorLoaded(wrapper)
|
||||
|
||||
expect(requests.some((request) => request.url === '/mail-attachments')).toBe(true)
|
||||
expect(wrapper.findAll('.upload-item__name').map((item) => item.text())).toEqual(['회의록.pdf', '체크리스트.xlsx'])
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
// 올린 파일을 빼면 처음 채운 모양(원본 첨부만)과 같다
|
||||
await wrapper.get('button[aria-label="회의록.pdf 첨부 취소"]').trigger('click')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,21 +7,27 @@
|
||||
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문에는 이미지를 넣지 않는다 (사용자 확정).
|
||||
// 화면 어디에 파일을 놓아도 첨부로 받는다 — 브라우저가 파일을 열어 쓰던 메일을 잃지 않게 (useWindowFileDrop).
|
||||
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
|
||||
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구(VAL_001).
|
||||
import { computed, defineAsyncComponent, nextTick, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구
|
||||
// (제목 VAL_001 · 본문 MAIL_007). 서버가 본문을 MAIL_007 로 거절해도 본문 아래에 같은 띠를 보인다.
|
||||
// 연 뒤에 받는 사람 · 제목 · 본문 · 첨부를 바꿨으면 떠나기 전에 묻는다 (디자인 README "나가기 전 확인", useLeaveConfirm) —
|
||||
// 보내는 중 · 보낸 뒤 · 세션이 끝났을 때(로그아웃 · 만료 · 다른 탭의 로그아웃)는 앱 안 이동을 묻지 않는다.
|
||||
// 보내는 중에 떠나면 요청은 이어지고, 결과는 토스트로만 알린다 — 떠난 양식은 건드리지 않고 끌고 오지도 않는다 (formGeneration).
|
||||
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, provide, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
|
||||
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
|
||||
import ComposeLeaveDialog from '@/components/mail/ComposeLeaveDialog.vue'
|
||||
import RecipientField from '@/components/mail/RecipientField.vue'
|
||||
import {
|
||||
MAIL_EDITOR_RETRY,
|
||||
loadMailEditor,
|
||||
type MailEditorHandle,
|
||||
} from '@/components/mail/editor/mail-editor-handle'
|
||||
import { getErrorMessage, toApiError } from '@/composables/useApi'
|
||||
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { useAttachmentUploads } from '@/composables/useAttachmentUploads'
|
||||
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
|
||||
@@ -36,7 +42,13 @@ import {
|
||||
type ComposeMode,
|
||||
} from '@/shared/constants/mail'
|
||||
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { SendMessagePayload } from '@/shared/types/mail'
|
||||
import {
|
||||
isSameComposeForm,
|
||||
snapshotComposeForm,
|
||||
type ComposeFormSnapshot,
|
||||
} from '@/shared/utils/compose-changes'
|
||||
import { isImageFile } from '@/shared/utils/editor-files'
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
@@ -110,8 +122,30 @@ const sending = ref<boolean>(false)
|
||||
const attempted = ref<boolean>(false)
|
||||
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
|
||||
const rejectedByServer = ref<boolean>(false)
|
||||
/** 보내기 직전에 잰 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도) */
|
||||
/**
|
||||
* 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도).
|
||||
* 본문은 보내기 직전에 잰 값이 넘었거나 서버가 MAIL_007 로 거절했을 때 (정화 전 비용 · 정화 뒤 크기 한도 포함)
|
||||
*/
|
||||
const lengthError = ref<'subject' | 'body' | null>(null)
|
||||
/**
|
||||
* 쓰기 화면을 다 연 때의 양식(받는 사람 · 제목 · 첨부) — 답장 · 전달로 처음 채운 내용은 "바꿈"이 아니다.
|
||||
* 이것과 달라졌거나 본문을 바꿨으면 떠나기 전에 묻는다 (디자인 README "나가기 전 확인"). 본문은 편집기가 잰다(bodyChanged)
|
||||
*/
|
||||
const openedForm = shallowRef<ComposeFormSnapshot>(
|
||||
snapshotComposeForm({ to: [], cc: [], bcc: [], subject: '', attachments: [] }),
|
||||
)
|
||||
/** 편집기를 연 뒤 본문을 바꿨다 — 편집기를 새로 만들면 처음부터 다시 잰다 */
|
||||
const bodyChanged = ref<boolean>(false)
|
||||
/** 보냈다 — 쓴 내용이 메일로 나갔으므로 떠날 때 묻지 않는다 (양식을 새로 채우면 풀린다) */
|
||||
const sent = ref<boolean>(false)
|
||||
/**
|
||||
* 양식 세대 — 양식을 새로 채울 때(resetForm)와 화면을 내릴 때 하나씩 올린다.
|
||||
* 보내기는 시작할 때 세대를 잡아 두고, 응답이 왔을 때 바뀌었으면 그 양식은 이미 없다 (보내는 중에 떠났거나 쓰기 종류가 바뀌었다).
|
||||
* 그때는 양식 상태(보냄 표시 · 발송 키 · 칸 안내 · 첨부 표시)를 건드리지 않고 이동하지도 않는다 — 결과는 토스트로만 알린다.
|
||||
*/
|
||||
let formGeneration = 0
|
||||
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
|
||||
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_004', 'MAIL_007']
|
||||
/**
|
||||
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
|
||||
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
|
||||
@@ -138,12 +172,23 @@ function isReply(value: ComposeMode): value is 'reply' | 'replyAll' {
|
||||
}
|
||||
|
||||
/** 답장·전달할 원본 메일 id */
|
||||
const sourceId = computed(() => queryId(route.query.forward) ?? queryId(route.query.replyTo))
|
||||
const mode = computed<ComposeMode>(() => {
|
||||
if (queryId(route.query.forward)) return 'forward'
|
||||
if (!queryId(route.query.replyTo)) return 'new'
|
||||
return route.query.all === '1' ? 'replyAll' : 'reply'
|
||||
})
|
||||
function sourceIdOf(query: LocationQuery): string | null {
|
||||
return queryId(query.forward) ?? queryId(query.replyTo)
|
||||
}
|
||||
|
||||
function modeOf(query: LocationQuery): ComposeMode {
|
||||
if (queryId(query.forward)) return 'forward'
|
||||
if (!queryId(query.replyTo)) return 'new'
|
||||
return query.all === '1' ? 'replyAll' : 'reply'
|
||||
}
|
||||
|
||||
/** 쓰기 종류(새 메일·답장·전달)와 원본 메일 — 바뀌면 양식을 새로 채운다 (같은 화면 안의 이동도 쓰던 내용을 버린다) */
|
||||
function draftKeyOf(query: LocationQuery): string {
|
||||
return `${modeOf(query)}:${sourceIdOf(query) ?? ''}`
|
||||
}
|
||||
|
||||
const sourceId = computed(() => sourceIdOf(route.query))
|
||||
const mode = computed<ComposeMode>(() => modeOf(route.query))
|
||||
const title = computed(() => COMPOSE_TITLES[mode.value])
|
||||
const attachmentHintText = computed(() =>
|
||||
attachmentHint(mailStore.attachmentMaxBytes, mailStore.attachmentTotalMaxBytes, MAIL_MAX_ATTACHMENTS),
|
||||
@@ -164,17 +209,56 @@ const showRecipientError = computed(
|
||||
)
|
||||
/** 문구는 에러 코드 사전에서만 온다 */
|
||||
const recipientErrorMessage = getErrorMessage('MAIL_002')
|
||||
/** 길이가 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
|
||||
const lengthErrorMessage = getErrorMessage('VAL_001')
|
||||
/** 제목이 255자를 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
|
||||
const subjectErrorMessage = getErrorMessage('VAL_001')
|
||||
/** 본문이 너무 길면 서버는 MAIL_007 로 거절한다 — 보내기 전에 막을 때(내 글만으로 넘음)와 서버가 거절했을 때 모두 이 문구 */
|
||||
const bodyErrorMessage = getErrorMessage('MAIL_007')
|
||||
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
|
||||
const canSend = computed(
|
||||
() =>
|
||||
editorReady.value && !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
|
||||
)
|
||||
|
||||
/** 지금 양식(받는 사람 · 제목 · 첨부) — 연 때의 양식(openedForm)과 견준다 */
|
||||
function snapshotForm(): ComposeFormSnapshot {
|
||||
return snapshotComposeForm({
|
||||
to: to.value,
|
||||
cc: cc.value,
|
||||
bcc: bcc.value,
|
||||
subject: subject.value,
|
||||
attachments: uploadItems.value,
|
||||
})
|
||||
}
|
||||
|
||||
const formChanged = computed(() => !isSameComposeForm(openedForm.value, snapshotForm()))
|
||||
/** 받는 사람 칸에 아직 토큰이 되지 않은 글이 있다 — 칸을 벗어나지 않고 떠나면(뒤로 가기 등) 확정되지 않은 채 사라진다 */
|
||||
const typingRecipient = computed(() =>
|
||||
[toField.value, ccField.value, bccField.value].some((field) => field?.hasDraft === true),
|
||||
)
|
||||
/** 떠나면 잃는 것이 있다 — 연 뒤에 바꿨고 아직 보내지 않았다 */
|
||||
const hasUnsentChanges = computed(
|
||||
() => !sent.value && (formChanged.value || bodyChanged.value || typingRecipient.value),
|
||||
)
|
||||
|
||||
/**
|
||||
* 떠나기 전 확인 — 앱 안 이동은 대화상자(ComposeLeaveDialog), 창 닫기 · 새로고침은 브라우저 확인창.
|
||||
* 세션이 끝났으면(로그아웃 · 만료 · 다른 탭의 로그아웃) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
|
||||
* 묻는 중에 세션이 끝나면 대화상자를 닫는다 — 세션 만료 대화상자와 겹치지 않는다.
|
||||
* 같은 쓰기 화면 안의 이동도 쓰기 종류나 원본이 바뀌면 양식을 새로 채우므로 묻는다 (답장 쓰다 "쓰기" 등).
|
||||
* 보내는 중에는 앱 안 이동을 묻지 않는다 — 이미 보내기로 했고, 요청은 이어지며 결과는 토스트로 알린다 (formGeneration).
|
||||
* 창 닫기 · 새로고침은 보내는 중에도 묻는다 — 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳이 없다.
|
||||
*/
|
||||
const { confirming: leaveConfirming, stay: stayOnCompose, leave: leaveCompose } = useLeaveConfirm({
|
||||
active: () => hasUnsentChanges.value && authStore.isLoggedIn && !sending.value,
|
||||
unloadActive: () => hasUnsentChanges.value && authStore.isLoggedIn,
|
||||
leavesOnUpdate: (next, current) => draftKeyOf(next.query) !== draftKeyOf(current.query),
|
||||
})
|
||||
|
||||
/** 편집기를 새로 만든다 — 다음에 그릴 때 draftHtml 로 시작한다 */
|
||||
function renewEditor() {
|
||||
editorKey.value += 1
|
||||
editorReady.value = false
|
||||
bodyChanged.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,6 +276,8 @@ function resetForm() {
|
||||
to.value = []
|
||||
cc.value = []
|
||||
bcc.value = []
|
||||
// 칸에 쓰다 만 주소도 버린다 — 새로 채운 양식이 처음부터 "바꿈"이 되지 않게
|
||||
for (const field of [toField.value, ccField.value, bccField.value]) field?.clearDraft()
|
||||
showCopies.value = false
|
||||
subject.value = ''
|
||||
draftHtml.value = ''
|
||||
@@ -202,8 +288,12 @@ function resetForm() {
|
||||
rejectedByServer.value = false
|
||||
lengthError.value = null
|
||||
resetUploads()
|
||||
// 양식을 새로 채우면 다른 메일이다
|
||||
// 양식을 새로 채우면 다른 메일이다 — 앞 양식에서 보내던 요청은 끝나도 이 양식을 건드리지 않는다 (formGeneration)
|
||||
formGeneration += 1
|
||||
sending.value = false
|
||||
sendKey = createIdempotencyKey()
|
||||
sent.value = false
|
||||
openedForm.value = snapshotForm()
|
||||
}
|
||||
|
||||
let prepareSeq = 0
|
||||
@@ -216,12 +306,14 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
|
||||
try {
|
||||
const original = await getMessage(messageId)
|
||||
if (seq !== prepareSeq) return
|
||||
/** 처음 채운 원본 첨부 — 불러오는 동안 사용자가 붙인 파일(창에 끌어다 놓기 등)은 여기 들지 않는다 ("바꿈"으로 남는다) */
|
||||
let filledAttachments: readonly number[] = []
|
||||
if (sourceMode === 'forward') {
|
||||
const draft = buildForwardDraft(original)
|
||||
subject.value = draft.subject
|
||||
draftHtml.value = draft.html
|
||||
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
|
||||
addExisting(draft.attachments)
|
||||
filledAttachments = addExisting(draft.attachments).map((item) => item.key)
|
||||
} else {
|
||||
const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode)
|
||||
to.value = draft.to.map(tokenFromAddress)
|
||||
@@ -232,6 +324,8 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
|
||||
// 답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다 — 편집기가 준비되면
|
||||
focusBodyWhenReady = true
|
||||
}
|
||||
// 처음 채운 내용은 "바꿈"이 아니다 — 받는 사람 · 제목은 불러오는 동안 막혀 있어 지금 값이 곧 채운 값이다
|
||||
openedForm.value = { ...snapshotForm(), attachments: filledAttachments }
|
||||
// 한도(텍스트 100,000 · 서식 500,000)를 넘는 원문은 편집기가 인용을 줄인다 (계약 2절 "답장·전달 초안의 길이")
|
||||
fitDraft.value = true
|
||||
renewEditor()
|
||||
@@ -256,6 +350,7 @@ function isFocusFree(): boolean {
|
||||
|
||||
function onEditorReady() {
|
||||
editorReady.value = true
|
||||
bodyChanged.value = false
|
||||
if (!focusBodyWhenReady) return
|
||||
focusBodyWhenReady = false
|
||||
void nextTick(() => {
|
||||
@@ -288,11 +383,11 @@ function settleSendKey(outcome: SendOutcome) {
|
||||
}
|
||||
|
||||
function notifySent() {
|
||||
const sent = mailStore.mailboxByRole('sent')
|
||||
const sentMailbox = mailStore.mailboxByRole('sent')
|
||||
toast.success(
|
||||
MAIL_SENT_MESSAGE,
|
||||
sent
|
||||
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sent.id } } }
|
||||
sentMailbox
|
||||
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } }
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
@@ -308,7 +403,7 @@ async function onSubmit() {
|
||||
}
|
||||
if (!canSend.value || !editorRef.value) return
|
||||
|
||||
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 VAL_001 로 거절한다)
|
||||
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 제목은 VAL_001, 본문은 MAIL_007 로 거절한다)
|
||||
const subjectText = subject.value.trim()
|
||||
if (subjectText.length > MAIL_SUBJECT_MAX_LENGTH) {
|
||||
lengthError.value = 'subject'
|
||||
@@ -319,8 +414,7 @@ async function onSubmit() {
|
||||
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
|
||||
const content = editorRef.value.getContentForSend()
|
||||
if (content === null) {
|
||||
lengthError.value = 'body'
|
||||
editorRef.value.focus()
|
||||
showBodyTooLong()
|
||||
return
|
||||
}
|
||||
lengthError.value = null
|
||||
@@ -337,25 +431,41 @@ async function onSubmit() {
|
||||
...(isReply(mode.value) && sourceId.value ? { inReplyTo: sourceId.value } : {}),
|
||||
}
|
||||
|
||||
/** 이 보내기가 속한 양식 — 응답이 왔을 때 바뀌었으면 양식을 건드리지 않고 토스트로만 알린다 */
|
||||
const generation = formGeneration
|
||||
sending.value = true
|
||||
try {
|
||||
// 받는 사람 거부(MAIL_002)와 첨부 만료(MAIL_004)는 이 화면이 해당 칸에 직접 안내한다.
|
||||
// 받는 사람 거부(MAIL_002) · 첨부 만료(MAIL_004) · 본문이 너무 김(MAIL_007)은 이 화면이 해당 칸에 직접 안내한다.
|
||||
// 그 밖의 실패는 인터셉터 알림(사전 문구)으로 충분하다 — AUTH_003 권한·출처, REQ_001 발송 한도·처리 중,
|
||||
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
|
||||
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애.
|
||||
// REQ_001 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
|
||||
await sendMessage(payload, { idempotencyKey: sendKey, silent: ['MAIL_002', 'MAIL_004'] })
|
||||
settleSendKey('sent')
|
||||
await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
|
||||
notifySent()
|
||||
// 보낸편지함 개수 · 사용량을 서버 기준으로 다시 맞춘다
|
||||
mailStore.syncCounts()
|
||||
// 보내는 중에 화면을 떠났거나 양식을 새로 채웠다 — 알리기만 하고 간 곳에 둔다
|
||||
if (generation !== formGeneration) return
|
||||
settleSendKey('sent')
|
||||
// 쓴 내용이 메일로 나갔다 — 이제 떠날 때 묻지 않는다
|
||||
sent.value = true
|
||||
await router.push(backTo.value)
|
||||
} catch (error) {
|
||||
const { code } = toApiError(error)
|
||||
const apiError = toApiError(error)
|
||||
if (generation !== formGeneration) {
|
||||
// 양식이 이미 없다 — 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
|
||||
if (SEND_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
const { code } = apiError
|
||||
// MAIL_007 도 키를 그대로 둔다 — 서버가 엔진에 넘기기 전에 거절했으므로, 줄인 뒤 같은 키로 보내면 된다 (계약 2절)
|
||||
settleSendKey(code)
|
||||
if (code === 'MAIL_002') {
|
||||
rejectedByServer.value = true
|
||||
toField.value?.focus()
|
||||
} else if (code === 'MAIL_007') {
|
||||
// 서버가 본문을 거절했다(정화 전 비용 · 정화 뒤 크기 한도 등) — 보내기 전 검사와 같은 띠 · 같은 초점
|
||||
showBodyTooLong()
|
||||
} else if (code === 'MAIL_004') {
|
||||
// 서버가 첨부를 찾지 못했다 — 어느 것인지 알 수 없으니 보낼 예정이던 첨부 전부를 "찾지 못함"으로 표시한다
|
||||
const marked = markMissing()
|
||||
@@ -370,7 +480,8 @@ async function onSubmit() {
|
||||
}
|
||||
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
|
||||
} finally {
|
||||
sending.value = false
|
||||
// 양식이 바뀌었으면 손대지 않는다 — 새 양식은 resetForm 이 이미 풀었고, 그 양식의 보내기가 진행 중일 수 있다
|
||||
if (generation === formGeneration) sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,11 +492,23 @@ function onSubjectEnter(event: KeyboardEvent) {
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 본문이 너무 길다(MAIL_007) — 본문 아래 띠를 보이고 본문으로 초점 (본문이 그 띠를 가리키고 잘못된 입력으로 읽힌다) */
|
||||
function showBodyTooLong() {
|
||||
lengthError.value = 'body'
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 길이 안내는 그 칸을 고치면 내린다 */
|
||||
function clearLengthError(field: 'subject' | 'body') {
|
||||
if (lengthError.value === field) lengthError.value = null
|
||||
}
|
||||
|
||||
/** 본문이 바뀌었다 — 길이 안내를 내리고, 연 때와 다른지 다시 잰다 (나가기 전 확인) */
|
||||
function onEditorChange() {
|
||||
clearLengthError('body')
|
||||
bodyChanged.value = editorRef.value?.isChanged() ?? false
|
||||
}
|
||||
|
||||
function pickFiles() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
@@ -397,8 +520,13 @@ function onFilesChosen(event: Event) {
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 화면을 떠났다 — 진행 중인 보내기가 끝나도 이 양식을 건드리거나 사용자를 끌고 오지 않는다
|
||||
formGeneration += 1
|
||||
})
|
||||
|
||||
// 쓰기 종류(새 메일·답장·전달)나 원본 메일이 바뀌면 양식을 새로 채운다
|
||||
const draftKey = computed(() => `${mode.value}:${sourceId.value ?? ''}`)
|
||||
const draftKey = computed(() => draftKeyOf(route.query))
|
||||
watch(
|
||||
draftKey,
|
||||
() => {
|
||||
@@ -531,7 +659,7 @@ watch(allRecipients, () => {
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ lengthErrorMessage }}</span>
|
||||
<span>{{ subjectErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<!-- 편집기를 불러오거나 원문을 채우는 동안에도 자리를 지켜 아래 줄이 뛰지 않게 한다 -->
|
||||
@@ -546,7 +674,7 @@ watch(allRecipients, () => {
|
||||
:described-by="lengthError === 'body' ? BODY_ERROR_ID : null"
|
||||
@ready="onEditorReady"
|
||||
@files="attachFromBody"
|
||||
@change="clearLengthError('body')"
|
||||
@change="onEditorChange"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
@@ -559,7 +687,7 @@ watch(allRecipients, () => {
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ lengthErrorMessage }}</span>
|
||||
<span>{{ bodyErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<div class="compose__attachments">
|
||||
@@ -625,6 +753,13 @@ watch(allRecipients, () => {
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 바꾼 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
|
||||
<ComposeLeaveDialog
|
||||
:open="leaveConfirming"
|
||||
@stay="stayOnCompose"
|
||||
@leave="leaveCompose"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export const MAIL_BODY_FONT_SIZE_PX = 14
|
||||
*/
|
||||
export const MAIL_BODY_LINE_HEIGHT = 1.75
|
||||
|
||||
/** 서식 본문(bodyHtml) 길이 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
|
||||
/** 서식 본문(bodyHtml) 길이 한도 — 계약 2절 "발송 본문" (넘기면 400 MAIL_007) */
|
||||
export const MAIL_BODY_HTML_MAX_LENGTH = 500_000
|
||||
|
||||
/** 빈 본문 안내 (디자인 원본 ComposeEditor "빈 본문") */
|
||||
|
||||
@@ -45,7 +45,9 @@ export const MAILBOX_EMPTY_HINTS: Readonly<Record<MailboxKind, string>> = {
|
||||
export const OUTGOING_MAILBOX_ROLES: readonly MailboxRole[] = ['sent', 'drafts']
|
||||
|
||||
// ── 발송 한도 (계약 2절 "발송 본문") ─────────────────────────
|
||||
/** 제목 — 넘기면 400 VAL_001 (입력칸이 이 길이에서 더 받지 않는다) */
|
||||
export const MAIL_SUBJECT_MAX_LENGTH = 255
|
||||
/** 텍스트 본문(body) — 넘기면 400 MAIL_007 (서식 본문 한도는 mail-editor.ts 의 MAIL_BODY_HTML_MAX_LENGTH) */
|
||||
export const MAIL_BODY_MAX_LENGTH = 100_000
|
||||
export const MAIL_MAX_RECIPIENTS = 100
|
||||
/**
|
||||
@@ -129,3 +131,27 @@ export type ComposeMode = keyof typeof COMPOSE_TITLES
|
||||
/** 발송 완료 토스트 (디자인 원본 States "보내기 완료") */
|
||||
export const MAIL_SENT_MESSAGE = '메일을 보냈습니다.'
|
||||
export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
|
||||
|
||||
/**
|
||||
* 쓰기 화면 나가기 전 확인 (디자인 README "나가기 전 확인") — 받는 사람 · 제목 · 본문 · 첨부를 바꾼 채 화면을 떠날 때.
|
||||
* [계속 쓰기]가 처음 초점이고 Esc 와 같다.
|
||||
*/
|
||||
export const COMPOSE_LEAVE_DIALOG = {
|
||||
title: '보내지 않은 메일이 있습니다',
|
||||
message: '이 화면을 나가면 쓰던 내용이 사라집니다.',
|
||||
stay: '계속 쓰기',
|
||||
leave: '나가기',
|
||||
} as const
|
||||
|
||||
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
|
||||
/**
|
||||
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
|
||||
* - htmlOmitted · unavailable: 서식 본문을 정화 한도 때문에 비웠다(isHtmlBodyOmitted) — 글자 본문이 있을 때 · 없을 때
|
||||
* - truncated: 보여 주는 본문이 읽기 한도(2MB)에서 잘렸다(isBodyTruncated)
|
||||
*/
|
||||
export const MAIL_BODY_NOTICES = {
|
||||
htmlOmitted: '본문이 복잡해 서식 없이 글자만 표시합니다.',
|
||||
unavailable: '본문이 너무 크거나 복잡해 표시할 수 없습니다.',
|
||||
truncated: '본문이 길어 앞부분만 표시합니다.',
|
||||
} as const
|
||||
export type MailBodyNoticeKind = keyof typeof MAIL_BODY_NOTICES
|
||||
|
||||
@@ -58,6 +58,16 @@ export interface MessageDetail extends MessageSummary {
|
||||
textBody: string | null
|
||||
/** 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 */
|
||||
hasBlockedImages: boolean
|
||||
/**
|
||||
* HTML 이 있었지만 서버의 정화 한도(너무 길거나 깊게 중첩됨)를 넘어 htmlBody 를 비웠으면 true
|
||||
* → textBody 를 서식 없이 보이며 안내한다 (textBody 도 없으면 "표시할 수 없음"). 필드가 없으면(이전 서버) false 로 본다
|
||||
*/
|
||||
isHtmlBodyOmitted?: boolean
|
||||
/**
|
||||
* 보여 주는 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 서버의 읽기 한도(2MB)에서 잘렸으면 true
|
||||
* → "앞부분만 표시" 안내 · 답장 · 전달 인용 끝에 생략 표시. 필드가 없으면 false 로 본다
|
||||
*/
|
||||
isBodyTruncated?: boolean
|
||||
attachments: Attachment[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isSameComposeForm, snapshotComposeForm, type ComposeFormFields } from './compose-changes'
|
||||
import { parseRecipient, tokenFromAddress, type RecipientToken } from './mail-address'
|
||||
|
||||
const DOYUN = tokenFromAddress({ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' })
|
||||
|
||||
function token(text: string): RecipientToken {
|
||||
const parsed = parseRecipient(text)
|
||||
if (!parsed) throw new Error('빈 토큰')
|
||||
return parsed
|
||||
}
|
||||
|
||||
function fields(partial: Partial<ComposeFormFields> = {}): ComposeFormFields {
|
||||
return { to: [DOYUN], cc: [], bcc: [], subject: 'Re: 일정', attachments: [{ key: 1 }], ...partial }
|
||||
}
|
||||
|
||||
describe('쓰기 양식의 바꿈 판정 (디자인 README "나가기 전 확인")', () => {
|
||||
const opened = snapshotComposeForm(fields())
|
||||
|
||||
it('연 때와 같으면 바꾸지 않은 것이다 — 썼다 지워 돌아온 것도 같다', () => {
|
||||
expect(isSameComposeForm(opened, snapshotComposeForm(fields()))).toBe(true)
|
||||
expect(isSameComposeForm(opened, snapshotComposeForm(fields({ to: [tokenFromAddress(DOYUN.address)] })))).toBe(true)
|
||||
})
|
||||
|
||||
it.each<[string, Partial<ComposeFormFields>]>([
|
||||
['받는 사람을 더함', { to: [DOYUN, token('junho.park@arasystem.it.kr')] }],
|
||||
['받는 사람을 뺌', { to: [] }],
|
||||
['같은 주소를 이름 없이 다시 씀', { to: [token('doyun.lee@arasystem.it.kr')] }],
|
||||
['참조를 더함', { cc: [token('infra@arasystem.it.kr')] }],
|
||||
['숨은참조를 더함', { bcc: [token('ceo@arasystem.it.kr')] }],
|
||||
['제목을 고침', { subject: 'Re: 일정 ' }],
|
||||
['첨부를 더함(올리는 중 포함)', { attachments: [{ key: 1 }, { key: 2 }] }],
|
||||
['전달 원본 첨부를 뺌', { attachments: [] }],
|
||||
])('%s', (_label, change) => {
|
||||
expect(isSameComposeForm(opened, snapshotComposeForm(fields(change)))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// 쓰기 화면의 "바꿈" 판정 — 순수 함수 (디자인 README "나가기 전 확인")
|
||||
// 쓰기 화면을 다 연 때(답장 · 전달 초안을 채운 뒤)의 양식을 찍어 두고, 지금 양식과 다르면 떠나기 전에 묻는다.
|
||||
// 본문은 편집기가 따로 잰다 (MailEditor 의 isChanged) — 여기서는 받는 사람 · 제목 · 첨부만 본다.
|
||||
|
||||
import type { RecipientToken } from '@/shared/utils/mail-address'
|
||||
|
||||
export interface ComposeFormSnapshot {
|
||||
/** 받는 사람 · 참조 · 숨은참조 — 토큰에 보이는 글자 (이름 · 주소를 고치면 달라진다) */
|
||||
to: readonly string[]
|
||||
cc: readonly string[]
|
||||
bcc: readonly string[]
|
||||
subject: string
|
||||
/** 첨부 목록 항목의 키 — 붙이거나(올리는 중 · 실패 포함) 빼면 달라진다 */
|
||||
attachments: readonly number[]
|
||||
}
|
||||
|
||||
export interface ComposeFormFields {
|
||||
to: readonly RecipientToken[]
|
||||
cc: readonly RecipientToken[]
|
||||
bcc: readonly RecipientToken[]
|
||||
subject: string
|
||||
attachments: readonly { key: number }[]
|
||||
}
|
||||
|
||||
const labels = (tokens: readonly RecipientToken[]) => tokens.map((token) => token.label)
|
||||
|
||||
export function snapshotComposeForm(fields: ComposeFormFields): ComposeFormSnapshot {
|
||||
return {
|
||||
to: labels(fields.to),
|
||||
cc: labels(fields.cc),
|
||||
bcc: labels(fields.bcc),
|
||||
subject: fields.subject,
|
||||
attachments: fields.attachments.map((item) => item.key),
|
||||
}
|
||||
}
|
||||
|
||||
function sameList<T>(a: readonly T[], b: readonly T[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index])
|
||||
}
|
||||
|
||||
/** 같은 양식인가 — 순서까지 본다 (썼다 지워 처음으로 돌아왔으면 같다) */
|
||||
export function isSameComposeForm(a: ComposeFormSnapshot, b: ComposeFormSnapshot): boolean {
|
||||
return (
|
||||
a.subject === b.subject &&
|
||||
sameList(a.to, b.to) &&
|
||||
sameList(a.cc, b.cc) &&
|
||||
sameList(a.bcc, b.bcc) &&
|
||||
sameList(a.attachments, b.attachments)
|
||||
)
|
||||
}
|
||||
@@ -67,6 +67,10 @@ describe('shouldRenewSendKey — 키는 성공과 MAIL_005 때만 바꾼다 (계
|
||||
},
|
||||
)
|
||||
|
||||
it('MAIL_007(본문이 너무 김) 뒤에도 같은 키 — 서버가 엔진에 넘기기 전에 거절했으므로 줄인 뒤 같은 키로 보낸다 (계약 2절)', () => {
|
||||
expect(shouldRenewSendKey('MAIL_007')).toBe(false)
|
||||
})
|
||||
|
||||
it('사전의 다른 모든 코드도 키를 유지한다 — 새 코드가 생겨도 바꾸는 경우는 늘지 않는다', () => {
|
||||
const renewing = (Object.keys(ERROR_CODE_MESSAGES) as ErrorCode[]).filter(shouldRenewSendKey)
|
||||
expect(renewing).toEqual(['MAIL_005'])
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import { mailBodyNotices } from './mail-body-notices'
|
||||
|
||||
const texts = (source: Parameters<typeof mailBodyNotices>[0]) => mailBodyNotices(source).map((item) => item.text)
|
||||
|
||||
describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽기 한도")', () => {
|
||||
it('서버가 다 준 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 메일도 같다', () => {
|
||||
expect(texts({ textBody: '본문', isHtmlBodyOmitted: false, isBodyTruncated: false })).toEqual([])
|
||||
expect(texts({ textBody: '본문' })).toEqual([])
|
||||
})
|
||||
|
||||
it('서식 본문을 비웠는데 글자 본문이 있으면 "서식 없이 글자만", 없으면(공백뿐 포함) "표시할 수 없음"', () => {
|
||||
expect(texts({ textBody: '글자 본문', isHtmlBodyOmitted: true })).toEqual([MAIL_BODY_NOTICES.htmlOmitted])
|
||||
expect(texts({ textBody: null, isHtmlBodyOmitted: true })).toEqual([MAIL_BODY_NOTICES.unavailable])
|
||||
expect(texts({ textBody: ' \n ', isHtmlBodyOmitted: true })).toEqual([MAIL_BODY_NOTICES.unavailable])
|
||||
})
|
||||
|
||||
it('보여 주는 본문이 잘렸으면 "앞부분만 표시"', () => {
|
||||
expect(mailBodyNotices({ textBody: null, isBodyTruncated: true })).toEqual([
|
||||
{ kind: 'truncated', text: MAIL_BODY_NOTICES.truncated },
|
||||
])
|
||||
})
|
||||
|
||||
it('둘 다면 생략 → 잘림 순서 — 보여 줄 본문이 없으면 잘림 안내는 두지 않는다', () => {
|
||||
expect(texts({ textBody: '긴 글자 본문', isHtmlBodyOmitted: true, isBodyTruncated: true })).toEqual([
|
||||
MAIL_BODY_NOTICES.htmlOmitted,
|
||||
MAIL_BODY_NOTICES.truncated,
|
||||
])
|
||||
expect(texts({ textBody: '', isHtmlBodyOmitted: true, isBodyTruncated: true })).toEqual([
|
||||
MAIL_BODY_NOTICES.unavailable,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 MessageDetail · "읽기 한도") — 순수 함수만 둔다
|
||||
// 순서는 생략(서식 없이 · 표시할 수 없음) → 잘림 (디자인 README "읽기 화면의 본문 안내").
|
||||
|
||||
import { MAIL_BODY_NOTICES, type MailBodyNoticeKind } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
|
||||
export interface MailBodyNotice {
|
||||
kind: MailBodyNoticeKind
|
||||
text: string
|
||||
}
|
||||
|
||||
type NoticeSource = Pick<MessageDetail, 'textBody' | 'isHtmlBodyOmitted' | 'isBodyTruncated'>
|
||||
|
||||
function notice(kind: MailBodyNoticeKind): MailBodyNotice {
|
||||
return { kind, text: MAIL_BODY_NOTICES[kind] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 위에 둘 안내 — 없으면 빈 목록. 표시 값이 없는(이전 서버) 메일은 안내하지 않는다.
|
||||
* 글자 본문은 공백뿐이면 없는 것으로 본다 — 화면(MailBodyFrame)에 보이는 것이 없다.
|
||||
*/
|
||||
export function mailBodyNotices(message: NoticeSource): MailBodyNotice[] {
|
||||
const notices: MailBodyNotice[] = []
|
||||
const omitted = message.isHtmlBodyOmitted === true
|
||||
const hasText = (message.textBody ?? '').trim() !== ''
|
||||
if (omitted) notices.push(notice(hasText ? 'htmlOmitted' : 'unavailable'))
|
||||
// 보여 줄 본문이 없으면("표시할 수 없음") 앞부분만 보인다는 안내는 맞지 않는다
|
||||
if (message.isBodyTruncated === true && !(omitted && !hasText)) notices.push(notice('truncated'))
|
||||
return notices
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import { MAIL_QUOTE_OMITTED_MARK, MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { buildForwardDraft, buildForwardSubject } from './mail-forward'
|
||||
|
||||
@@ -88,4 +88,10 @@ describe('buildForwardDraft', () => {
|
||||
expect(fits.subject).toBe(`Fwd: ${'가'.repeat(250)}`)
|
||||
expect(over.subject).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
|
||||
})
|
||||
|
||||
it('서버가 읽기 한도에서 자른 원문(isBodyTruncated)이면 인용 끝에 생략 표시', () => {
|
||||
const { html } = buildForwardDraft(message({ textBody: '앞부분', isBodyTruncated: true }), 'Asia/Seoul')
|
||||
|
||||
expect(html.endsWith(`<p>앞부분</p><p>${MAIL_QUOTE_OMITTED_MARK}</p></blockquote>`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_FRAME_CSP, buildMailDocument, escapeHtml, sanitizeMailHtml } from './mail-html'
|
||||
import { MAIL_FRAME_CSP, buildMailDocument, displayedHtmlBody, escapeHtml, sanitizeMailHtml } from './mail-html'
|
||||
|
||||
// 보안 핵심 — 서버 정화에 빈틈이 있어도 클라이언트가 한 번 더 걸러 내는지 확인한다.
|
||||
describe('sanitizeMailHtml', () => {
|
||||
@@ -126,6 +126,20 @@ describe('buildMailDocument', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('displayedHtmlBody — 보여 줄 서식 본문 (읽기 화면 · 답장 · 전달 인용이 같은 규칙)', () => {
|
||||
it('null 이거나 공백뿐이면 없다 — 그때는 텍스트를 보인다', () => {
|
||||
expect(displayedHtmlBody(null)).toBeNull()
|
||||
expect(displayedHtmlBody(undefined)).toBeNull()
|
||||
expect(displayedHtmlBody('')).toBeNull()
|
||||
// 외부 그림 하나뿐이던 HTML 을 서버가 정화하면 줄바꿈만 남는다
|
||||
expect(displayedHtmlBody('\n\n\n\n\n')).toBeNull()
|
||||
})
|
||||
|
||||
it('글이 있으면 받은 그대로 돌려준다', () => {
|
||||
expect(displayedHtmlBody('\n<p>본문</p>\n')).toBe('\n<p>본문</p>\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('특수 문자를 모두 바꾼다', () => {
|
||||
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe('<a href="x">'&'</a>')
|
||||
|
||||
@@ -130,16 +130,27 @@ export interface MailBodySource {
|
||||
textBody: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 보여 줄 서식 본문 — null 이거나 공백뿐이면 없다(null). 그때는 textBody 를 보인다.
|
||||
* 서버(정화 결과가 공백뿐이면 htmlBody 를 비운다) · 계약 2절과 같은 규칙이다.
|
||||
* 읽기 화면(buildMailDocument)과 답장 · 전달 인용(mail-quote.ts 의 originalBodyHtml)이 함께 써서 둘이 같은 본문을 고른다
|
||||
* — 예) 외부 이미지 하나뿐인 HTML 은 정화하면 줄바꿈만 남는다. 읽기 화면은 글자 본문을 보이므로 인용도 글자 본문을 쓴다.
|
||||
*/
|
||||
export function displayedHtmlBody(htmlBody: string | null | undefined): string | null {
|
||||
return htmlBody?.trim() ? htmlBody : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 → iframe srcdoc 문서.
|
||||
* HTML 이 있으면 정화해서, 없으면 텍스트를 이스케이프해서 넣는다.
|
||||
* HTML 이 있으면 정화해서, 없으면(공백뿐 포함 — displayedHtmlBody) 텍스트를 이스케이프해서 넣는다.
|
||||
* <base target="_blank"> 로 남은 링크도 모두 새 창으로 연다.
|
||||
*/
|
||||
export function buildMailDocument(source: MailBodySource): string {
|
||||
const html = source.htmlBody?.trim()
|
||||
const content = html
|
||||
? sanitizeMailHtml(html)
|
||||
: `<div class="mail-text">${escapeHtml(source.textBody ?? '')}</div>`
|
||||
const html = displayedHtmlBody(source.htmlBody)
|
||||
const content =
|
||||
html !== null
|
||||
? sanitizeMailHtml(html.trim())
|
||||
: `<div class="mail-text">${escapeHtml(source.textBody ?? '')}</div>`
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
QUOTE_SOURCE_MAX_LENGTH,
|
||||
buildQuoteHtml,
|
||||
forwardQuoteRows,
|
||||
hasVisibleQuoteContent,
|
||||
originalBodyHtml,
|
||||
replyQuoteRows,
|
||||
textToParagraphHtml,
|
||||
@@ -52,6 +53,36 @@ describe('originalBodyHtml', () => {
|
||||
expect(html).toBe(`${head}${OMITTED}`)
|
||||
})
|
||||
|
||||
it('서버가 읽기 한도에서 자른 원문(isBodyTruncated)이면 여기서 자르지 않았어도 끝에 생략 표시 — 서식 · 텍스트 모두', () => {
|
||||
expect(originalBodyHtml({ htmlBody: '<p>앞부분</p>', textBody: '텍스트', isBodyTruncated: true })).toBe(
|
||||
`<p>앞부분</p>${OMITTED}`,
|
||||
)
|
||||
expect(originalBodyHtml({ htmlBody: null, textBody: '앞부분', isBodyTruncated: true })).toBe(
|
||||
`<p>앞부분</p>${OMITTED}`,
|
||||
)
|
||||
// 잘리지 않았다고 알려 왔거나 알리지 않았으면(이전 서버) 그대로
|
||||
expect(originalBodyHtml({ htmlBody: '<p>전부</p>', textBody: null, isBodyTruncated: false })).toBe('<p>전부</p>')
|
||||
})
|
||||
|
||||
it('공백뿐인 서식 본문은 없는 것으로 보고 텍스트를 인용한다 — 읽기 화면과 같은 본문 (외부 그림만 있던 뉴스레터 등)', () => {
|
||||
const html = originalBodyHtml({ htmlBody: '\n\n', textBody: '원문', isBodyTruncated: true })
|
||||
|
||||
expect(html).toContain('<p>원문</p>')
|
||||
expect(html).toBe(`<p>원문</p>${OMITTED}`)
|
||||
expect(originalBodyHtml({ htmlBody: ' \n\t ', textBody: '한 줄\n두 줄' })).toBe('<p>한 줄</p><p>두 줄</p>')
|
||||
})
|
||||
|
||||
it('서버도 잘랐고 여기서도 잘랐으면 생략 표시는 한 번만', () => {
|
||||
const html = originalBodyHtml({
|
||||
htmlBody: 'x'.repeat(QUOTE_SOURCE_MAX_LENGTH + 10),
|
||||
textBody: null,
|
||||
isBodyTruncated: true,
|
||||
})
|
||||
|
||||
expect(html).toBe(`${'x'.repeat(QUOTE_SOURCE_MAX_LENGTH)}${OMITTED}`)
|
||||
expect(html.split(MAIL_QUOTE_OMITTED_MARK)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('그림(서버가 넣은 data: 그림 포함)은 자르기 전에 걷어 낸다 — 큰 그림 뒤의 글이 생략 표시 없이 사라지지 않게', () => {
|
||||
const picture = `<img src="data:image/png;base64,${'A'.repeat(QUOTE_SOURCE_MAX_LENGTH + 100_000)}" alt="서명">`
|
||||
const html = originalBodyHtml({ htmlBody: `<p>앞 글</p><p>${picture}</p><p>뒤쪽 본문 내용</p>`, textBody: null })
|
||||
@@ -61,6 +92,74 @@ describe('originalBodyHtml', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('그림뿐인 원문 — 인용은 그림을 받지 않으므로 걷어 내면 보이는 것이 없다', () => {
|
||||
const PICTURE = '<img src="data:image/png;base64,iVBORw0KGgo=" alt="회사 로고">'
|
||||
|
||||
it('글자 본문이 있으면 그 문단을 인용한다 (읽기 화면은 그대로 그림을 보인다)', () => {
|
||||
expect(originalBodyHtml({ htmlBody: `<div>${PICTURE}</div>`, textBody: '회사 로고\n안녕하세요' })).toBe(
|
||||
'<p>회사 로고</p><p>안녕하세요</p>',
|
||||
)
|
||||
// 링크로 감싼 그림 · 빈 문단 · 공백 문자 참조만 남아도 보이는 것이 없다
|
||||
expect(
|
||||
originalBodyHtml({ htmlBody: `<p><a href="https://x.kr">${PICTURE}</a></p><p> </p>`, textBody: '본문' }),
|
||||
).toBe('<p>본문</p>')
|
||||
})
|
||||
|
||||
it('글자 본문도 없으면 인용 머리만 남는다 — 지금과 같다', () => {
|
||||
const body = originalBodyHtml({ htmlBody: `<div>${PICTURE}</div>`, textBody: null })
|
||||
const html = buildQuoteHtml(replyQuoteRows(HEADER, 'Asia/Seoul'), body)
|
||||
|
||||
expect(body).toBe('<p></p>')
|
||||
expect(html.endsWith('</tbody></table><p></p></blockquote>')).toBe(true)
|
||||
expect(html).not.toContain('<img')
|
||||
})
|
||||
|
||||
it('잘린 원문(isBodyTruncated)이면 글자 본문 끝에 생략 표시를 한 번 둔다', () => {
|
||||
expect(originalBodyHtml({ htmlBody: PICTURE, textBody: '앞부분', isBodyTruncated: true })).toBe(`<p>앞부분</p>${OMITTED}`)
|
||||
})
|
||||
|
||||
it('글자가 있는 원문은 그대로 — 그림만 걷어 낸다', () => {
|
||||
expect(originalBodyHtml({ htmlBody: `<p>안녕하세요</p><p>${PICTURE}</p>`, textBody: '텍스트 본문' })).toBe(
|
||||
'<p>안녕하세요</p><p></p>',
|
||||
)
|
||||
})
|
||||
|
||||
it('글자 없는 표 · 가로줄만 남아도 보이는 내용이 아니다 — 글자 본문을 인용한다', () => {
|
||||
expect(
|
||||
originalBodyHtml({ htmlBody: `<table><tbody><tr><td>${PICTURE}</td></tr></tbody></table>`, textBody: '텍스트' }),
|
||||
).toBe('<p>텍스트</p>')
|
||||
expect(originalBodyHtml({ htmlBody: `${PICTURE}<hr>`, textBody: '텍스트' })).toBe('<p>텍스트</p>')
|
||||
})
|
||||
|
||||
it('그림만 표로 늘어놓은 뉴스레터 — 글자 본문의 문단을 인용한다', () => {
|
||||
const layout =
|
||||
'<table width="600" align="center"><tbody>' +
|
||||
`<tr><td align="center">${PICTURE}</td></tr>` +
|
||||
`<tr><td><table><tbody><tr><td>${PICTURE}</td><td> </td><td>${PICTURE}</td></tr></tbody></table></td></tr>` +
|
||||
'</tbody></table>'
|
||||
|
||||
expect(originalBodyHtml({ htmlBody: layout, textBody: '10월 소식\n신제품을 소개합니다.' })).toBe(
|
||||
'<p>10월 소식</p><p>신제품을 소개합니다.</p>',
|
||||
)
|
||||
})
|
||||
|
||||
it('글자가 든 표는 그대로 원문 HTML 을 인용한다 — 그림만 걷어 낸다', () => {
|
||||
expect(
|
||||
originalBodyHtml({
|
||||
htmlBody: `<table><tbody><tr><td>${PICTURE}</td><td>견적 합계</td></tr></tbody></table>`,
|
||||
textBody: '텍스트',
|
||||
}),
|
||||
).toBe('<table><tbody><tr><td></td><td>견적 합계</td></tr></tbody></table>')
|
||||
})
|
||||
|
||||
it('hasVisibleQuoteContent — 태그를 걷어 내고 공백이 아닌 글자가 남았는가 ( · U+00A0 은 글자가 아니다)', () => {
|
||||
expect(hasVisibleQuoteContent('<p>  </p><div><br></div>')).toBe(false)
|
||||
expect(hasVisibleQuoteContent('<table><tbody><tr><td></td></tr></tbody></table><HR>')).toBe(false)
|
||||
expect(hasVisibleQuoteContent('<p>&</p>')).toBe(true)
|
||||
expect(hasVisibleQuoteContent('<p><strong>글</strong></p>')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('인용 머리', () => {
|
||||
it('답장 — 보낸 사람 · 날짜 · 제목 (제목이 없으면 "(제목 없음)")', () => {
|
||||
expect(replyQuoteRows(HEADER, 'Asia/Seoul')).toEqual([
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { formatMailDateTime } from '@/shared/utils/format'
|
||||
import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { truncateText } from '@/shared/utils/mail-draft'
|
||||
import { escapeHtml } from '@/shared/utils/mail-html'
|
||||
import { displayedHtmlBody, escapeHtml } from '@/shared/utils/mail-html'
|
||||
|
||||
export interface QuoteRow {
|
||||
label: string
|
||||
@@ -37,17 +37,43 @@ export const QUOTE_SOURCE_MAX_LENGTH = 2_000_000
|
||||
*/
|
||||
const IMAGE_TAG = /<img\b[^>]*>/gi
|
||||
|
||||
/** 태그 하나 — IMAGE_TAG 와 같은 가정(정화한 HTML 이라 속성 값 안의 `>` 는 > 다)으로 태그를 걷어 내 글자만 본다 */
|
||||
const ANY_TAG = /<[^>]*>/g
|
||||
/** 공백 문자 참조( ) — 글자로 세지 않는다 (U+00A0 등 공백 문자 그대로인 것은 \s 가 걸러 낸다) */
|
||||
const SPACE_ENTITY = /&(?:nbsp|#160|#x0*a0);/gi
|
||||
|
||||
/**
|
||||
* 그림을 걷어 낸 원문 HTML 에 보이는 내용이 남았는가 — 태그를 걷어 내고 공백이 아닌 글자가 남았는가 ( · U+00A0 은 글자가 아니다).
|
||||
* 글자 없는 표 · 가로줄도 보이는 내용으로 치지 않는다 — 그림만 표로 늘어놓은 뉴스레터는 걷어 내면 빈 칸만 남는다.
|
||||
* 그림뿐이던 원문은 이렇게 아무것도 남지 않는다 — 그때 인용은 글자 본문을 쓴다 (originalBodyHtml).
|
||||
*/
|
||||
export function hasVisibleQuoteContent(html: string): boolean {
|
||||
return /\S/.test(html.replace(ANY_TAG, '').replace(SPACE_ENTITY, ''))
|
||||
}
|
||||
|
||||
/** 생략 표시 문단 — 편집기의 맞춤(fit-draft)과 같은 글 */
|
||||
const OMITTED_PARAGRAPH = `<p>${escapeHtml(MAIL_QUOTE_OMITTED_MARK)}</p>`
|
||||
|
||||
/** HTML 을 길이에 맞춰 자른다 — 태그 한가운데서 자르지 않고, 잘랐으면 끝에 생략 표시를 둔다 */
|
||||
function truncateHtml(html: string, maxLength: number): string {
|
||||
if (html.length <= maxLength) return html
|
||||
/** 인용할 원문 HTML — cut: 길이 때문에 여기서 잘랐다 (끝에 생략 표시를 둔다) */
|
||||
interface QuotedSource {
|
||||
html: string
|
||||
cut: boolean
|
||||
}
|
||||
|
||||
/** HTML 을 길이에 맞춰 자른다 — 태그 한가운데서 자르지 않는다 */
|
||||
function truncateHtml(html: string, maxLength: number): QuotedSource {
|
||||
if (html.length <= maxLength) return { html, cut: false }
|
||||
let cut = truncateText(html, maxLength)
|
||||
// 마지막 태그가 닫히지 않았으면 그 태그 앞에서 자른다
|
||||
const lastOpen = cut.lastIndexOf('<')
|
||||
if (lastOpen > cut.lastIndexOf('>')) cut = cut.slice(0, lastOpen)
|
||||
return `${cut}${OMITTED_PARAGRAPH}`
|
||||
return { html: cut, cut: true }
|
||||
}
|
||||
|
||||
/** 텍스트를 길이에 맞춰 자른 뒤 문단 HTML 로 — 서로게이트 쌍 가운데는 자르지 않는다 */
|
||||
function truncateTextToParagraphs(text: string, maxLength: number): QuotedSource {
|
||||
if (text.length <= maxLength) return { html: textToParagraphHtml(text), cut: false }
|
||||
return { html: textToParagraphHtml(truncateText(text, maxLength)), cut: true }
|
||||
}
|
||||
|
||||
const NO_SUBJECT = '(제목 없음)'
|
||||
@@ -61,14 +87,24 @@ export function textToParagraphHtml(text: string): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
type OriginalSource = Pick<MessageDetail, 'htmlBody' | 'textBody'>
|
||||
type OriginalSource = Pick<MessageDetail, 'htmlBody' | 'textBody' | 'isBodyTruncated'>
|
||||
|
||||
/** 원문 본문 — 서버가 정화한 htmlBody(그림을 걷어 낸 것), 없으면 textBody 를 문단으로. 아주 길면 자르고 생략 표시 */
|
||||
/**
|
||||
* 원문 본문 — 서버가 정화한 htmlBody(그림을 걷어 낸 것), null 이거나 공백뿐이면 textBody 를 문단으로.
|
||||
* 고르는 규칙은 읽기 화면과 같다 (mail-html.ts 의 displayedHtmlBody) — 화면에 보인 본문을 인용한다. 아주 길면 자른다.
|
||||
* 다만 그림을 걷어 내니 보이는 것이 남지 않았으면(그림뿐이던 원문) 공백뿐인 HTML 과 같이 textBody 를 쓴다 —
|
||||
* 인용은 그림을 받지 않으므로, 읽기 화면에 보인 그림 대신 글자 본문이라도 남긴다 (읽기 화면은 그대로 그림을 보인다).
|
||||
* 여기서 잘랐거나 서버가 읽기 한도(2MB)에서 잘라 보냈으면(isBodyTruncated — 인용하는 본문이 그 본문이다)
|
||||
* 끝에 생략 표시를 한 번 둔다 — 원문이 거기서 끝나는 것처럼 보이지 않게.
|
||||
*/
|
||||
export function originalBodyHtml(message: OriginalSource): string {
|
||||
if (message.htmlBody) return truncateHtml(message.htmlBody.replace(IMAGE_TAG, ''), QUOTE_SOURCE_MAX_LENGTH)
|
||||
const text = message.textBody ?? ''
|
||||
if (text.length <= QUOTE_SOURCE_MAX_LENGTH) return textToParagraphHtml(text)
|
||||
return `${textToParagraphHtml(truncateText(text, QUOTE_SOURCE_MAX_LENGTH))}${OMITTED_PARAGRAPH}`
|
||||
const html = displayedHtmlBody(message.htmlBody)?.replace(IMAGE_TAG, '') ?? null
|
||||
const source =
|
||||
html !== null && hasVisibleQuoteContent(html)
|
||||
? truncateHtml(html, QUOTE_SOURCE_MAX_LENGTH)
|
||||
: truncateTextToParagraphs(message.textBody ?? '', QUOTE_SOURCE_MAX_LENGTH)
|
||||
const omitted = source.cut || message.isBodyTruncated === true
|
||||
return omitted ? `${source.html}${OMITTED_PARAGRAPH}` : source.html
|
||||
}
|
||||
|
||||
type HeaderSource = Pick<MessageDetail, 'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import { MAIL_QUOTE_OMITTED_MARK, MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { buildReplyDraft, buildReplyRecipients, buildReplySubject } from './mail-reply'
|
||||
|
||||
@@ -75,6 +75,57 @@ describe('buildReplyRecipients', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildReplyRecipients — 내가 보낸 메일(보낸 사람이 나)에 답장', () => {
|
||||
const MYSELF = { name: '김아라', email: ME }
|
||||
|
||||
it('답장 — 원래 받는 사람(나는 뺀다). 참조는 넣지 않는다', () => {
|
||||
const sent = message({ from: MYSELF, to: [JUNHO, MYSELF, DOYUN], cc: [INFRA] })
|
||||
|
||||
expect(buildReplyRecipients(sent, ME, 'reply')).toEqual({ to: [JUNHO, DOYUN], cc: [] })
|
||||
})
|
||||
|
||||
it('전체 답장 — 원래 받는 사람과 원래 참조 (둘 다 나는 뺀다)', () => {
|
||||
const sent = message({ from: MYSELF, to: [JUNHO], cc: [INFRA, MYSELF] })
|
||||
|
||||
expect(buildReplyRecipients(sent, ME, 'replyAll')).toEqual({ to: [JUNHO], cc: [INFRA] })
|
||||
})
|
||||
|
||||
it('회신 주소(Reply-To)가 있어도 원래 받는 사람에게 — 내 메일의 회신 주소는 남이 답할 때 쓰는 값이다', () => {
|
||||
const sent = message({ from: MYSELF, replyTo: [INFRA], to: [JUNHO] })
|
||||
|
||||
expect(buildReplyRecipients(sent, ME, 'reply')).toEqual({ to: [JUNHO], cc: [] })
|
||||
expect(buildReplyRecipients(sent, ME, 'replyAll')).toEqual({ to: [JUNHO], cc: [] })
|
||||
})
|
||||
|
||||
it.each(['reply', 'replyAll'] as const)(
|
||||
'나에게만 보낸 메일이면 받는 사람 칸을 비우지 않고 나에게 답장한다 (%s)',
|
||||
(mode) => {
|
||||
const toSelf = message({ from: MYSELF, to: [MYSELF], cc: [] })
|
||||
|
||||
expect(buildReplyRecipients(toSelf, ME, mode)).toEqual({ to: [MYSELF], cc: [] })
|
||||
},
|
||||
)
|
||||
|
||||
it('받는 사람 칸이 비고 참조만 있으면 — 받는 사람은 나, 전체 답장은 원래 참조를 그대로 둔다', () => {
|
||||
const ccOnly = message({ from: MYSELF, to: [], cc: [INFRA] })
|
||||
|
||||
expect(buildReplyRecipients(ccOnly, ME, 'reply')).toEqual({ to: [MYSELF], cc: [] })
|
||||
expect(buildReplyRecipients(ccOnly, ME, 'replyAll')).toEqual({ to: [MYSELF], cc: [INFRA] })
|
||||
})
|
||||
|
||||
it('보낸 사람이 나인지는 앞뒤 공백 · 대소문자를 가리지 않는다', () => {
|
||||
const sent = message({ from: { name: '김아라', email: ' ARA.Kim@AraSystem.it.kr ' }, to: [JUNHO] })
|
||||
|
||||
expect(buildReplyRecipients(sent, ` ${ME.toUpperCase()} `, 'reply').to).toEqual([JUNHO])
|
||||
})
|
||||
|
||||
it('로그인한 주소를 모르면(빈 값) 지금처럼 보낸 사람에게', () => {
|
||||
const sent = message({ from: MYSELF, to: [JUNHO] })
|
||||
|
||||
expect(buildReplyRecipients(sent, '', 'reply').to).toEqual([MYSELF])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildReplyDraft', () => {
|
||||
it('받는 사람 · Re: 제목 · inReplyTo(메일 id) 를 채운다', () => {
|
||||
const draft = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul')
|
||||
@@ -118,6 +169,28 @@ describe('buildReplyDraft', () => {
|
||||
expect(html).not.toContain('텍스트')
|
||||
})
|
||||
|
||||
it('서버가 읽기 한도에서 자른 원문(isBodyTruncated)이면 인용 끝에 생략 표시 — 편집기가 줄이지 않아도', () => {
|
||||
const { html } = buildReplyDraft(
|
||||
message({ htmlBody: '<p>앞부분</p>', isBodyTruncated: true }),
|
||||
ME,
|
||||
'reply',
|
||||
'Asia/Seoul',
|
||||
)
|
||||
|
||||
expect(html.endsWith(`<p>앞부분</p><p>${MAIL_QUOTE_OMITTED_MARK}</p></blockquote>`)).toBe(true)
|
||||
})
|
||||
|
||||
it('서식 본문이 공백뿐이면(외부 그림만 있던 메일) 읽기 화면처럼 글자 본문을 인용한다 — 잘린 원문이면 생략 표시도', () => {
|
||||
const { html } = buildReplyDraft(
|
||||
message({ htmlBody: '\n\n\n\n\n', textBody: '원문 글자 본문', isBodyTruncated: true }),
|
||||
ME,
|
||||
'reply',
|
||||
'Asia/Seoul',
|
||||
)
|
||||
|
||||
expect(html.endsWith(`<p>원문 글자 본문</p><p>${MAIL_QUOTE_OMITTED_MARK}</p></blockquote>`)).toBe(true)
|
||||
})
|
||||
|
||||
it('계약 한도에 맞춘다 — 원래 제목 251자면 "Re: " 를 붙여 255자, 252자면 255자에서 자른다', () => {
|
||||
const fits = buildReplyDraft(message({ subject: '가'.repeat(251) }), ME, 'reply')
|
||||
const over = buildReplyDraft(message({ subject: '가'.repeat(252) }), ME, 'reply')
|
||||
|
||||
@@ -31,11 +31,32 @@ export function buildReplySubject(subject: string): string {
|
||||
|
||||
type ReplySource = Pick<MessageDetail, 'from' | 'to' | 'cc' | 'replyTo'>
|
||||
|
||||
/**
|
||||
* 내가 보낸 메일(보낸 사람이 나)에 답장 — 그 메일을 받은 사람에게 보낸다 (Reply-To 는 보지 않는다).
|
||||
* - 답장: 원래 받는 사람 · 전체 답장: 원래 받는 사람과 원래 참조 — 둘 다 나는 뺀다
|
||||
* - 받는 사람이 비면(나에게만 보낸 메일) 나에게 답장한다 — 받는 사람 칸을 비워 두지 않는다
|
||||
*/
|
||||
function replyToOwnMessage(
|
||||
message: ReplySource,
|
||||
me: Address,
|
||||
notMe: (address: Address) => boolean,
|
||||
mode: ReplyMode,
|
||||
): { to: Address[]; cc: Address[] } {
|
||||
const recipients = uniqueAddresses(message.to).filter(notMe)
|
||||
const to = recipients.length > 0 ? recipients : [me]
|
||||
if (mode === 'reply') return { to, cc: [] }
|
||||
const cc = uniqueAddresses(message.cc).filter(
|
||||
(address) => notMe(address) && !to.some((target) => sameAddress(target, address)),
|
||||
)
|
||||
return { to, cc }
|
||||
}
|
||||
|
||||
/**
|
||||
* 답장 받는 사람.
|
||||
* - 답장: Reply-To 가 있으면 그 주소, 없으면 보낸 사람
|
||||
* - 전체 답장: 위 주소 + 원래 받는 사람(받는 사람 칸) + 원래 참조(참조 칸)
|
||||
* 어느 쪽이든 나 자신은 뺀다. 내가 보낸 메일에 답장하면 원래 받는 사람에게 보낸다.
|
||||
* 어느 쪽이든 나 자신은 뺀다 (주소는 앞뒤 공백 · 대소문자를 가리지 않고 비교한다).
|
||||
* 내가 보낸 메일에 답장하면 원래 받는 사람에게 보낸다 (replyToOwnMessage — 나에게만 보낸 메일이면 나에게).
|
||||
*/
|
||||
export function buildReplyRecipients(
|
||||
message: ReplySource,
|
||||
@@ -45,9 +66,12 @@ export function buildReplyRecipients(
|
||||
const me = normalizeEmail(myEmail)
|
||||
const notMe = (address: Address) => normalizeEmail(address.email) !== me
|
||||
|
||||
const primary = message.replyTo.length > 0 ? message.replyTo : message.from ? [message.from] : []
|
||||
const { from } = message
|
||||
if (from && me !== '' && !notMe(from)) return replyToOwnMessage(message, from, notMe, mode)
|
||||
|
||||
const primary = message.replyTo.length > 0 ? message.replyTo : from ? [from] : []
|
||||
const others = primary.filter(notMe)
|
||||
// 내가 보낸 메일이면(보낸 사람이 나) 원래 받는 사람에게 답장한다
|
||||
// 답장할 주소가 나뿐이면(Reply-To 가 나) 원래 받는 사람에게 답장한다
|
||||
const base = others.length > 0 ? others : message.to.filter(notMe)
|
||||
|
||||
if (mode === 'reply') return { to: uniqueAddresses(base), cc: [] }
|
||||
|
||||
@@ -77,6 +77,26 @@ describe('acceptStyleValue — 들어온 값(붙여 넣기 · 원문 인용)을
|
||||
expect(acceptStyleValue('font-size', '80pt')).toBeNull()
|
||||
})
|
||||
|
||||
it('보이지 않을 만큼 작은 글자는 받지 않는다 — 하한 8px 은 pt 를 px 로 바꾼 값으로 본다 (6pt = 8px · 5.25pt = 7px)', () => {
|
||||
expect(acceptStyleValue('font-size', '8px')).toBe('8px')
|
||||
expect(acceptStyleValue('font-size', '6pt')).toBe('8px')
|
||||
expect(acceptStyleValue('font-size', '5.25pt')).toBeNull()
|
||||
expect(acceptStyleValue('font-size', '7.99px')).toBeNull()
|
||||
expect(acceptStyleValue('font-size', '1px')).toBeNull()
|
||||
})
|
||||
|
||||
it('거의 투명한 글자 색(알파 0.3 미만)은 받지 않는다 — 배경 색 · 테두리 색은 알파 0~1 그대로', () => {
|
||||
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 0.3)')).toBe('rgba(0, 0, 0, 0.3)')
|
||||
expect(acceptStyleValue('color', 'rgba(0,0,0,.35)')).toBe('rgba(0,0,0,.35)')
|
||||
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 1)')).toBe('rgba(0, 0, 0, 1)')
|
||||
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 0.1)')).toBeNull()
|
||||
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 0)')).toBeNull()
|
||||
expect(acceptStyleValue('color', 'rgb(0, 0, 0)')).toBe('rgb(0, 0, 0)')
|
||||
expect(acceptStyleValue('background-color', 'rgba(0, 0, 0, 0.1)')).toBe('rgba(0, 0, 0, 0.1)')
|
||||
expect(acceptStyleValue('border-color', 'rgba(0, 0, 0, 0)')).toBe('rgba(0, 0, 0, 0)')
|
||||
expect(isOutgoingStyleValue('border', '1px solid rgba(0, 0, 0, 0)')).toBe(true)
|
||||
})
|
||||
|
||||
it('lengthToPx — px · pt 만 (기준 크기를 모르는 em · % 는 null)', () => {
|
||||
expect(lengthToPx('40px')).toBe(40)
|
||||
expect(lengthToPx('36pt')).toBe(48)
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
// 쓰는 곳: 서식 편집기가 붙여 넣은 글 · 원문 인용을 읽어 들일 때 이 형식에 맞는 값만 받는다 (editor/mail-format-extensions.ts).
|
||||
// 서버만 지우면 편집기에서만 보이는(또는 숨는) 모양이 생긴다 — 예: font-size:0 으로 숨긴 글이 받는 사람에게는 보인다.
|
||||
// 그래서 편집기에서도 처음부터 버린다 — 편집기에 보이는 모양 = 받는 사람이 보는 모양.
|
||||
// 숨긴 글을 막는 하한은 두 가지만 막는다 — 8px 보다 작은 글자 크기, 알파가 0.3 보다 작은 글자 색(rgba).
|
||||
// 그런 값은 그 서식만 버려 기본 크기 · 색으로 보인다 (계약 2절 "서식 본문", 디자인 README "붙여 넣은 글 · 원문 인용의 서식").
|
||||
// 바탕과 같은 색의 글(흰 바탕에 흰 글 등)은 막지 않는다 — 받는 쪽에서 글 뒤에 깔릴 바탕 색을 알 수 없기 때문이다.
|
||||
|
||||
// ── style 값 형식 (서버 STYLE_RULES 와 같다 — 모두 처음부터 끝까지, 대소문자 무시) ──────────────
|
||||
|
||||
@@ -14,13 +17,18 @@
|
||||
const PX = String.raw`(?:0|\d{1,3}(?:\.\d{1,2})?px)`
|
||||
/** 칸 폭 — px · % · auto */
|
||||
const WIDTH = String.raw`(?:${PX}|\d{1,3}(?:\.\d{1,2})?%|auto)`
|
||||
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만) */
|
||||
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만, 알파 0~1) — 배경 색 · 테두리 색 */
|
||||
const COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d{1,3})\s*)?\))`
|
||||
/**
|
||||
* 글자 색 — COLOR 와 같되 rgb()/rgba() 에 알파가 있으면 0.3~1 (거의 투명한 글자로 숨긴 글을 보내지 않게).
|
||||
* hex 는 알파가 없다 (8자리 hex 는 COLOR 에서도 받지 않는다).
|
||||
*/
|
||||
const TEXT_COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:1|0?\.[3-9]\d{0,2})\s*)?\))`
|
||||
const BORDER_STYLE = String.raw`(?:none|hidden|dotted|dashed|solid|double)`
|
||||
const BORDER_WIDTH = String.raw`(?:${PX}|thin|medium|thick)`
|
||||
const BORDER_PART = String.raw`(?:${BORDER_WIDTH}|${BORDER_STYLE}|${COLOR})`
|
||||
/** 글자 크기 — px 만, 1~99.99px */
|
||||
const FONT_SIZE = String.raw`(?:[1-9]\d?(?:\.\d{1,2})?px)`
|
||||
/** 글자 크기 — px 만, 8~99.99px. 8px 아래는 보이지 않게 숨긴 글로 보고 받지 않는다 */
|
||||
const FONT_SIZE = String.raw`(?:(?:[89]|[1-9]\d)(?:\.\d{1,2})?px)`
|
||||
/** 줄 간격 — 단위 없는 숫자 1~3, 소수 둘째 자리까지 */
|
||||
const LINE_HEIGHT = String.raw`(?:[12](?:\.\d{1,2})?|3(?:\.0{1,2})?)`
|
||||
|
||||
@@ -67,6 +75,7 @@ const FONT_NAME = (() => {
|
||||
const exactly = (pattern: string) => new RegExp(`^${pattern}$`, 'i')
|
||||
|
||||
const COLOR_VALUE = exactly(COLOR)
|
||||
const TEXT_COLOR_VALUE = exactly(TEXT_COLOR)
|
||||
const PX_VALUE = exactly(PX)
|
||||
const WIDTH_VALUE = exactly(WIDTH)
|
||||
/** margin · padding 줄임형 — px 길이 1~4개 */
|
||||
@@ -78,7 +87,7 @@ const BORDER_VALUE = exactly(String.raw`${BORDER_PART}(?:\s+${BORDER_PART}){0,2}
|
||||
* 여기 없는 속성(display · position · visibility · opacity · text-decoration 등)은 서버가 지운다.
|
||||
*/
|
||||
export const OUTGOING_STYLE_RULES = {
|
||||
color: COLOR_VALUE,
|
||||
color: TEXT_COLOR_VALUE,
|
||||
'background-color': COLOR_VALUE,
|
||||
'font-family': exactly(String.raw`${FONT_NAME}(?:\s*,\s*${FONT_NAME}){0,9}`),
|
||||
'font-size': exactly(FONT_SIZE),
|
||||
@@ -148,7 +157,8 @@ export function lengthToPx(value: string): number | null {
|
||||
/**
|
||||
* 들어온 style 값 → 보낼 수 있는 값. 형식이 틀리면 null (그 서식은 받지 않는다).
|
||||
* - `!important` 는 떼고 본다 (서버도 값만 본다)
|
||||
* - 글자 크기: pt 는 px 로 바꾸고 소수 셋째 자리부터는 반올림한다 (워드 10pt → 13.33px, 아웃룩의 13.3333px)
|
||||
* - 글자 크기: pt 는 px 로 바꾸고 소수 셋째 자리부터는 반올림한다 (워드 10pt → 13.33px, 아웃룩의 13.3333px).
|
||||
* 하한은 바꾼 px 로 본다 — 6pt(8px)는 받고 5.25pt(7px)는 받지 않는다
|
||||
*/
|
||||
export function acceptStyleValue(property: OutgoingStyleProperty, raw: string | null | undefined): string | null {
|
||||
if (typeof raw !== 'string') return null
|
||||
|
||||
@@ -13,6 +13,9 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
|
||||
['color', 'rgb(23, 92, 211)', true],
|
||||
['color', 'RGB(1,2,3)', true],
|
||||
['color', 'rgba(0, 0, 0, 0.5)', true],
|
||||
['color', 'rgba(0, 0, 0, 0.3)', true],
|
||||
['color', 'rgba(0, 0, 0, 0.29)', false],
|
||||
['color', 'rgba(0, 0, 0, 0)', false],
|
||||
['color', 'red', false],
|
||||
['color', 'transparent', false],
|
||||
['color', 'currentcolor', false],
|
||||
@@ -26,11 +29,14 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
|
||||
['background-color', '#FFF1A8', true],
|
||||
['background-color', 'yellow', false],
|
||||
['background-color', 'transparent', false],
|
||||
// 글자 크기 — px 만, 1~99.99px
|
||||
['background-color', 'rgba(0, 0, 0, 0)', true],
|
||||
// 글자 크기 — px 만, 8~99.99px (8px 미만은 보이지 않게 숨긴 글로 보고 지운다)
|
||||
['font-size', '14px', true],
|
||||
['font-size', '12.5px', true],
|
||||
['font-size', '13.33px', true],
|
||||
['font-size', '1px', true],
|
||||
['font-size', '1px', false],
|
||||
['font-size', '8px', true],
|
||||
['font-size', '7.99px', false],
|
||||
['font-size', '99.99px', true],
|
||||
['font-size', '0', false],
|
||||
['font-size', '0px', false],
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
// 단위 테스트 전용 — 앱과 같은 이름의 라우트를 가진 메모리 라우터 (화면은 빈 컴포넌트)
|
||||
import { h, type FunctionalComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
|
||||
import { computed, h, type FunctionalComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter, matchedRouteKey, type Router } from 'vue-router'
|
||||
|
||||
/**
|
||||
* RouterView 없이 바로 띄운 화면에 "지금 그리는 경로"를 준다 — 화면의 onBeforeRouteLeave · onBeforeRouteUpdate 가 그 경로에 걸린다
|
||||
* (RouterView 가 하위 화면에 주는 값과 같다). 띄우기 전에 router.push 로 그 경로에 가 있어야 한다.
|
||||
* 사용: mount(Page, { global: { plugins: [pinia, router], provide: routeViewProvide(router) } })
|
||||
*/
|
||||
export function routeViewProvide(router: Router): Record<symbol, unknown> {
|
||||
return { [matchedRouteKey as symbol]: computed(() => router.currentRoute.value.matched.at(-1)) }
|
||||
}
|
||||
|
||||
const Empty: FunctionalComponent = () => h('div')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user