From bbd88e707a5a904258ed744060fd374af7d2a011 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 27 Oct 2026 13:00:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=82=98=EA=B0=80=EA=B8=B0=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=C2=B7=EC=9D=BD=EA=B8=B0=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EC=95=88=EB=82=B4=C2=B7=EB=82=B4=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EB=8B=B5=EC=9E=A5=20=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EC=82=AC=EB=9E=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쓰기 화면을 연 뒤 바꾼 것이 있으면 떠나기 전에 묻는다 — 앱 안 이동은 확인 대화상자, 탭 닫기·새로고침은 브라우저 확인창. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않고, 보내는 중에 떠나면 결과는 알림으로만 알린다(떠난 양식은 건드리지 않는다). 대화상자는 공용 ModalDialog 로 모았다 — 세션 만료 대화상자도 같은 것을 쓴다 - 본문 길이 초과는 MAIL_007 문구로 본문 아래에 알린다 (서버가 같은 코드를 돌려줘도 같다, 발송 키는 그대로) - 읽기 화면에 본문 안내(서식 없이 표시·표시할 수 없음·앞부분만 표시). 잘린 원문을 인용하면 생략 표시를 둔다. 인용할 원문은 읽기 화면과 같은 규칙으로 고른다 — 공백뿐인 HTML·그림만 남는 HTML 은 글자 본문으로 - 붙여 넣은 글·원문 인용에서 8px 미만 글자 크기와 알파 0.3 미만 글자 색을 버린다 (서버와 같은 규칙, 같은 표본 표 105행) - 내가 보낸 메일에 답장하면 받는 사람이 원래 받는 사람이다 (전에는 받는 사람 칸이 비었다) Co-Authored-By: Claude Opus 5.5 --- .../components/feedback/ModalDialog.spec.ts | 111 ++++ .../src/components/feedback/ModalDialog.vue | 182 ++++++ .../feedback/SessionExpiredDialog.vue | 128 +--- .../components/mail/ComposeLeaveDialog.vue | 56 ++ .../src/components/mail/MessageReader.spec.ts | 63 ++ .../src/components/mail/MessageReader.vue | 19 + .../src/components/mail/RecipientField.vue | 9 +- .../src/components/mail/editor/MailEditor.vue | 22 +- .../components/mail/editor/fit-draft.spec.ts | 2 +- .../src/components/mail/editor/fit-draft.ts | 2 +- .../mail/editor/mail-editor-handle.ts | 5 + .../editor/mail-format-extensions.spec.ts | 16 + frontend/src/composables/useApi.spec.ts | 1 + frontend/src/composables/useApi.ts | 9 + .../composables/useAttachmentUploads.spec.ts | 12 + .../src/composables/useAttachmentUploads.ts | 4 +- frontend/src/composables/useLeaveConfirm.ts | 97 +++ .../mail/ComposePage.editor-load.spec.ts | 33 +- frontend/src/pages/mail/ComposePage.spec.ts | 577 +++++++++++++++++- frontend/src/pages/mail/ComposePage.vue | 195 +++++- frontend/src/shared/constants/mail-editor.ts | 2 +- frontend/src/shared/constants/mail.ts | 26 + frontend/src/shared/types/mail.ts | 10 + .../src/shared/utils/compose-changes.spec.ts | 38 ++ frontend/src/shared/utils/compose-changes.ts | 50 ++ .../src/shared/utils/idempotency-key.spec.ts | 4 + .../shared/utils/mail-body-notices.spec.ts | 35 ++ .../src/shared/utils/mail-body-notices.ts | 30 + .../src/shared/utils/mail-forward.spec.ts | 8 +- frontend/src/shared/utils/mail-html.spec.ts | 16 +- frontend/src/shared/utils/mail-html.ts | 21 +- frontend/src/shared/utils/mail-quote.spec.ts | 99 +++ frontend/src/shared/utils/mail-quote.ts | 58 +- frontend/src/shared/utils/mail-reply.spec.ts | 75 ++- frontend/src/shared/utils/mail-reply.ts | 30 +- .../shared/utils/outgoing-html-rules.spec.ts | 20 + .../src/shared/utils/outgoing-html-rules.ts | 20 +- .../src/test-utils/outgoing-style-samples.ts | 10 +- frontend/src/test-utils/router.ts | 13 +- 39 files changed, 1912 insertions(+), 196 deletions(-) create mode 100644 frontend/src/components/feedback/ModalDialog.spec.ts create mode 100644 frontend/src/components/feedback/ModalDialog.vue create mode 100644 frontend/src/components/mail/ComposeLeaveDialog.vue create mode 100644 frontend/src/composables/useLeaveConfirm.ts create mode 100644 frontend/src/shared/utils/compose-changes.spec.ts create mode 100644 frontend/src/shared/utils/compose-changes.ts create mode 100644 frontend/src/shared/utils/mail-body-notices.spec.ts create mode 100644 frontend/src/shared/utils/mail-body-notices.ts diff --git a/frontend/src/components/feedback/ModalDialog.spec.ts b/frontend/src/components/feedback/ModalDialog.spec.ts new file mode 100644 index 0000000..288b0bb --- /dev/null +++ b/frontend/src/components/feedback/ModalDialog.spec.ts @@ -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(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']) + }) +}) diff --git a/frontend/src/components/feedback/ModalDialog.vue b/frontend/src/components/feedback/ModalDialog.vue new file mode 100644 index 0000000..b7be5d4 --- /dev/null +++ b/frontend/src/components/feedback/ModalDialog.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/src/components/feedback/SessionExpiredDialog.vue b/frontend/src/components/feedback/SessionExpiredDialog.vue index 6dab928..baec51f 100644 --- a/frontend/src/components/feedback/SessionExpiredDialog.vue +++ b/frontend/src/components/feedback/SessionExpiredDialog.vue @@ -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(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() -}) - - diff --git a/frontend/src/components/mail/ComposeLeaveDialog.vue b/frontend/src/components/mail/ComposeLeaveDialog.vue new file mode 100644 index 0000000..aa125b3 --- /dev/null +++ b/frontend/src/components/mail/ComposeLeaveDialog.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/components/mail/MessageReader.spec.ts b/frontend/src/components/mail/MessageReader.spec.ts index 3a10575..4e3bc02 100644 --- a/frontend/src/components/mail/MessageReader.spec.ts +++ b/frontend/src/components/mail/MessageReader.spec.ts @@ -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>) { + 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: '

앞부분

', + 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 diff --git a/frontend/src/components/mail/MessageReader.vue b/frontend/src/components/mail/MessageReader.vue index 38837d5..21f44f3 100644 --- a/frontend/src/components/mail/MessageReader.vue +++ b/frontend/src/components/mail/MessageReader.vue @@ -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() { + +

+ + {{ notice.text }} +

+

('') const inputRef = ref(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 })