generated from ttipo/project-base
feat(frontend): 새 메일 확인·메일 검색·여러 통 선택과 처리 화면
- 화면이 보이는 동안 1분마다 바뀐 것을 묻고 목록·메일함 숫자·탭 제목 "(N)" 을 맞춘다 — 늦게 온 응답은 목록 변경 세대로 버린다 - 상단 검색 칸 → /mail/search (범위 전환, 결과 없음 안내에 엔진 한계) - 표·분할 보기에서 여러 통 고르기와 도구 줄, 이동 메뉴, 휴지통 이동 + 되돌리기, 휴지통에서만 확인 뒤 영구 삭제 - 읽기 화면의 삭제도 DELETE 대신 휴지통 이동 · 확인 뒤 영구 삭제 — 다른 창이 휴지통에 넣은 메일을 확인 없이 지우지 않게 - 되돌리기 알림은 띄운 세션에서만 움직인다 — 메일 id 는 계정마다 겹칠 수 있다 - 메일 스토어를 기능별 setup 모듈로 나눴다 (공개 API 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { listLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore, type MailBulkResult } from '@/stores/mail.store'
|
||||
|
||||
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
|
||||
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
|
||||
// - 이동 · 삭제(휴지통 밖 — 휴지통으로 옮긴다) · 영구 삭제(휴지통에서만, 확인 대화상자를 거친 뒤): 알림은 useMailMoveFeedback
|
||||
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
|
||||
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
|
||||
|
||||
export function useBulkMailActions() {
|
||||
const mailStore = useMailStore()
|
||||
const router = useRouter()
|
||||
const feedback = useMailMoveFeedback()
|
||||
|
||||
/** 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다 (읽음 · 중요 표시는 막지 않는다) */
|
||||
const busy = ref<boolean>(false)
|
||||
|
||||
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 */
|
||||
const allFlagged = computed<boolean>(
|
||||
() => mailStore.checkedMessages.length > 0 && mailStore.checkedMessages.every((message) => message.isFlagged),
|
||||
)
|
||||
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
|
||||
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
|
||||
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
|
||||
const moveTargets = computed<Mailbox[]>(() =>
|
||||
mailStore.orderedMailboxes
|
||||
.map((node) => node.mailbox)
|
||||
.filter(
|
||||
(mailbox) =>
|
||||
mailbox.id !== mailStore.currentMailboxId && mailbox.role !== 'drafts' && mailbox.role !== 'trash',
|
||||
),
|
||||
)
|
||||
|
||||
async function markRead(isRead: boolean): Promise<void> {
|
||||
await mailStore.patchChecked({ isRead })
|
||||
}
|
||||
|
||||
/** 중요 표시 — 모두 중요면 해제, 아니면 모두 중요로 */
|
||||
async function toggleFlagged(): Promise<void> {
|
||||
await mailStore.patchChecked({ isFlagged: !allFlagged.value })
|
||||
}
|
||||
|
||||
/** 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 (기록을 쌓지 않는다) */
|
||||
async function leaveRemovedMessage(result: MailBulkResult): Promise<void> {
|
||||
const openId = mailStore.selectedId
|
||||
const source = mailStore.listSource
|
||||
if (result.stale || !openId || !source || !result.doneIds.includes(openId)) return
|
||||
if (mailStore.messages.some((message) => message.id === openId)) return
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
|
||||
async function moveTo(mailbox: Mailbox): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveChecked(mailbox.id)
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox.name)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 휴지통 밖의 삭제 — 휴지통으로 옮긴다 (DELETE 가 아니다 — 그사이 다른 창이 휴지통으로 옮긴 메일을 영구 삭제하지 않게) */
|
||||
async function moveToTrash(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
const trash = await mailStore.resolveMailboxByRole('trash')
|
||||
if ('error' in trash) {
|
||||
notifyApiError(trash.error)
|
||||
return
|
||||
}
|
||||
await moveTo(trash.mailbox)
|
||||
}
|
||||
|
||||
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
|
||||
async function purge(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.purgeChecked()
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announcePurge(result)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { busy, allFlagged, inTrash, moveTargets, markRead, toggleFlagged, moveTo, moveToTrash, purge }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, nextTick, type EffectScope } from 'vue'
|
||||
|
||||
import { useDraftTrash } from '@/composables/useDraftTrash'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore, type ToastAction } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
|
||||
// 쓰기 화면의 휴지통 · 되돌리기 (디자인 README "2단계 › 임시 저장") — 되돌리기는 알림을 띄운 세션에서만 움직인다
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 1, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [mailbox('drafts', 'drafts', '임시보관함'), mailbox('trash', 'trash', '휴지통')]
|
||||
|
||||
function summary(id: string): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['drafts'],
|
||||
from: null,
|
||||
to: [],
|
||||
subject: '',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDraftTrash (쓰기 화면의 휴지통)', () => {
|
||||
let scope: EffectScope
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().startSession(USER)
|
||||
scope = effectScope()
|
||||
requests = installMockApi(({ method, url, data }) => {
|
||||
if (url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
if (method === 'PATCH') return ok({ ...summary('d1'), mailboxIds: [(data as { mailboxId: string }).mailboxId] })
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function discard(): Promise<ToastAction> {
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
const trash = scope.run(() => useDraftTrash())
|
||||
if (!trash) throw new Error('scope')
|
||||
await trash.discard('d1')
|
||||
const action = useToastStore().toasts[0]?.action
|
||||
if (!action || !('run' in action)) throw new Error('되돌리기 단추가 없다')
|
||||
return action
|
||||
}
|
||||
|
||||
const patchesTo = (mailboxId: string) =>
|
||||
requests.filter(
|
||||
(request) => request.method === 'PATCH' && (request.data as { mailboxId?: string }).mailboxId === mailboxId,
|
||||
)
|
||||
|
||||
it('휴지통으로 옮기고(PATCH) [되돌리기] — 누르면 임시보관함으로 되돌린다', async () => {
|
||||
const action = await discard()
|
||||
expect(patchesTo('trash')).toHaveLength(1)
|
||||
expect(useToastStore().toasts[0]?.message).toBe('임시 저장한 메일을 휴지통으로 옮겼습니다.')
|
||||
|
||||
if ('run' in action) action.run()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchesTo('drafts')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('그사이 로그아웃했으면(다시 로그인했어도) 되돌리기는 아무것도 하지 않고, 알림도 닫힌다', async () => {
|
||||
const action = await discard()
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
if ('run' in action) action.run()
|
||||
await flushPromises()
|
||||
expect(patchesTo('drafts')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage, notifyApiError, toApiError } from './useApi'
|
||||
import { notifyApiError, toApiError } from './useApi'
|
||||
import { useMail } from './useMail'
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_DISCARDED_MESSAGE, DRAFT_RESTORED_MESSAGE, DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
@@ -21,18 +21,14 @@ export function useDraftTrash() {
|
||||
const toast = useToast()
|
||||
|
||||
async function moveToTrash(draftId: string): Promise<DraftTrashResult> {
|
||||
let trash = mailStore.mailboxByRole('trash')
|
||||
if (!trash) {
|
||||
// 메일함을 아직 못 받았다(받는 중 · 실패) — 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
|
||||
const loadError = await mailStore.loadMailboxes({ background: true })
|
||||
trash = mailStore.mailboxByRole('trash')
|
||||
if (!trash) {
|
||||
notifyApiError(loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') })
|
||||
return 'failed'
|
||||
}
|
||||
// 메일함을 아직 못 받았으면(받는 중 · 실패) 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
|
||||
const trash = await mailStore.resolveMailboxByRole('trash')
|
||||
if ('error' in trash) {
|
||||
notifyApiError(trash.error)
|
||||
return 'failed'
|
||||
}
|
||||
try {
|
||||
await updateMessage(draftId, { mailboxId: trash.id }, { silent: ['RES_001'] })
|
||||
await updateMessage(draftId, { mailboxId: trash.mailbox.id }, { silent: ['RES_001'] })
|
||||
return 'moved'
|
||||
} catch (error) {
|
||||
// 그 밖의 실패는 인터셉터가 알렸다
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
|
||||
import { listKeyOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 새 메일 확인으로 목록을 맞출 때의 스크롤 · 알림 줄 (디자인 README "3단계 › 새 메일")
|
||||
// - 맨 위를 보고 있으면 새 메일이 그냥 위에 생긴다
|
||||
// - 목록을 내려 본 채면 보던 행을 같은 자리에 두고(위에 행이 생겨도 화면이 밀리지 않게), 머리 아래에 "새 메일 N통 · 맨 위로"를 띄운다
|
||||
// — 누르거나 스스로 맨 위로 올라가면 사라진다. 다른 목록으로 바꾸면 사라진다
|
||||
// 넓은 화면은 목록 칸이, 좁은 화면은 창 전체가 스크롤된다 (AppShell) — 둘 다 같은 규칙이다.
|
||||
// 행은 data-row-id 를 단다 (분할 보기 행 · 표 행).
|
||||
|
||||
/** 이만큼(px)보다 덜 내려갔으면 맨 위로 본다 */
|
||||
const SCROLL_TOP_TOLERANCE = 1
|
||||
|
||||
interface RowAnchor {
|
||||
id: string
|
||||
/** 화면에서의 위쪽 위치 */
|
||||
top: number
|
||||
}
|
||||
|
||||
export function useListArrivalNotice(sectionRef: Ref<HTMLElement | null>, topRef: Ref<HTMLElement | null>) {
|
||||
const mailStore = useMailStore()
|
||||
/** 알림 줄의 "N통" — 0 이면 줄이 없다 */
|
||||
const noticeCount = ref<number>(0)
|
||||
|
||||
function sectionScrolled(): boolean {
|
||||
return (sectionRef.value?.scrollTop ?? 0) > SCROLL_TOP_TOLERANCE
|
||||
}
|
||||
|
||||
/** 목록을 내려 본 채인지 — 목록 칸이 스크롤되었거나(넓은 화면) 창이 목록 위쪽을 지나 스크롤되었다(좁은 화면) */
|
||||
function isScrolledDown(): boolean {
|
||||
const section = sectionRef.value
|
||||
if (!section) return false
|
||||
return sectionScrolled() || section.getBoundingClientRect().top < -SCROLL_TOP_TOLERANCE
|
||||
}
|
||||
|
||||
function rows(): HTMLElement[] {
|
||||
return Array.from(sectionRef.value?.querySelectorAll<HTMLElement>('[data-row-id]') ?? [])
|
||||
}
|
||||
|
||||
/** 머리 묶음 바로 아래에 보이는 첫 행 — 새 행이 위에 생겨도 이 행이 같은 자리에 남게 한다 */
|
||||
function firstVisibleRow(): RowAnchor | null {
|
||||
const line = topRef.value?.getBoundingClientRect().bottom ?? 0
|
||||
for (const row of rows()) {
|
||||
const rect = row.getBoundingClientRect()
|
||||
if (rect.bottom > line && row.dataset.rowId) return { id: row.dataset.rowId, top: rect.top }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function keepAnchor(anchor: RowAnchor, viaSection: boolean) {
|
||||
const row = rows().find((element) => element.dataset.rowId === anchor.id)
|
||||
if (!row) return
|
||||
const delta = row.getBoundingClientRect().top - anchor.top
|
||||
if (Math.abs(delta) < SCROLL_TOP_TOLERANCE) return
|
||||
const section = sectionRef.value
|
||||
if (viaSection && section) section.scrollTop += delta
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
|
||||
/** 맨 위로 — 알림 줄을 치우고 맨 위의 메일에 초점을 준다 (키보드 사용자가 새 메일부터 보게) */
|
||||
function goTop() {
|
||||
const section = sectionRef.value
|
||||
noticeCount.value = 0
|
||||
if (!section) return
|
||||
section.scrollTop = 0
|
||||
if (section.getBoundingClientRect().top < 0 && typeof section.scrollIntoView === 'function') {
|
||||
section.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
section.querySelector<HTMLElement>('[data-row-id] [data-message-id]')?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (noticeCount.value > 0 && !isScrolledDown()) noticeCount.value = 0
|
||||
}
|
||||
|
||||
// 목록을 바꾸기 직전(sync — 화면은 아직 옛 목록)에 보던 행을 잡아 두고, 바뀐 뒤 그 행을 같은 자리에 되돌린다
|
||||
watch(
|
||||
() => mailStore.listSync,
|
||||
(sync) => {
|
||||
if (!sync) return
|
||||
const scrolled = isScrolledDown()
|
||||
const viaSection = sectionScrolled()
|
||||
const anchor = scrolled ? firstVisibleRow() : null
|
||||
void nextTick(() => {
|
||||
if (anchor) keepAnchor(anchor, viaSection)
|
||||
else if (sectionRef.value && !scrolled) sectionRef.value.scrollTop = 0
|
||||
if (scrolled && sync.addedAbove > 0) noticeCount.value += sync.addedAbove
|
||||
})
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
// 다른 목록(메일함 · 검색)으로 바꾸면 알림 줄을 치운다
|
||||
watch(
|
||||
() => (mailStore.listSource ? listKeyOf(mailStore.listSource) : null),
|
||||
() => {
|
||||
noticeCount.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
sectionRef.value?.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sectionRef.value?.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
})
|
||||
|
||||
return { noticeCount, goTop }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, type Ref } from 'vue'
|
||||
|
||||
import { listKeyOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
/**
|
||||
* 목록 보기의 표 위치 기억 — 메일을 열면 표가 사라지므로, 사라질 때 스크롤을 기억했다가 "목록으로" 돌아오면 되살리고
|
||||
* 보던 메일의 제목 링크로 초점을 돌려준다(키보드 사용자가 자리를 잃지 않게). 메일함 · 검색 결과마다 따로 기억한다.
|
||||
* 불러온 페이지는 스토어에 그대로 있다. 넓은 화면은 칸(section)이, 좁은 화면은 창이 스크롤된다.
|
||||
*/
|
||||
export function useListPositionMemory(sectionRef: Ref<HTMLElement | null>, enabled: () => boolean) {
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const source = mailStore.listSource
|
||||
if (!enabled() || !source) return
|
||||
viewStore.rememberListPosition({
|
||||
listKey: listKeyOf(source),
|
||||
scrollTop: sectionRef.value?.scrollTop ?? 0,
|
||||
windowY: window.scrollY,
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const source = mailStore.listSource
|
||||
if (!enabled() || !source) return
|
||||
const saved = viewStore.takeListPosition(listKeyOf(source))
|
||||
const focusId = viewStore.takeReturnFocus()
|
||||
if (!saved && !focusId) return
|
||||
await nextTick()
|
||||
if (saved) {
|
||||
if (sectionRef.value) sectionRef.value.scrollTop = saved.scrollTop
|
||||
if (saved.windowY > 0) window.scrollTo({ top: saved.windowY })
|
||||
}
|
||||
if (focusId) {
|
||||
const links = sectionRef.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? []
|
||||
const link = Array.from(links).find((element) => element.dataset.messageId === focusId)
|
||||
link?.focus({ preventScroll: Boolean(saved) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -13,10 +13,15 @@ import type {
|
||||
DraftSaved,
|
||||
MailQuota,
|
||||
MailSignature,
|
||||
MailState,
|
||||
Mailbox,
|
||||
MessageDeletionRequest,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
MessageListQuery,
|
||||
MessageSummary,
|
||||
MessagesPatch,
|
||||
MessagesPatched,
|
||||
RecipientSuggestion,
|
||||
RecipientSuggestionQuery,
|
||||
SaveSignaturePayload,
|
||||
@@ -64,7 +69,7 @@ function startBrowserDownload(url: string): void {
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
// 메일 API Composable — docs/mail/api-contract.md 2절의 모든 엔드포인트
|
||||
// 메일 API Composable — docs/mail/api-contract.md 2절의 엔드포인트 (DELETE /messages/{id} 만 웹은 쓰지 않는다 — 아래 updateMessage 뒤 설명)
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
|
||||
export function useMail() {
|
||||
const api = useApi()
|
||||
@@ -78,7 +83,18 @@ export function useMail() {
|
||||
return api.get<MailQuota, MailQuota>('/mail-quota', options)
|
||||
}
|
||||
|
||||
/** 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다 */
|
||||
/**
|
||||
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
|
||||
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
|
||||
*/
|
||||
function getMailState(options?: MailRequestOptions): Promise<MailState> {
|
||||
return api.get<MailState, MailState>('/mail-state', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다.
|
||||
* 검색(q)은 mailboxId 가 있으면 그 메일함, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
|
||||
*/
|
||||
function listMessages(
|
||||
query: MessageListQuery,
|
||||
options?: MailRequestOptions,
|
||||
@@ -102,9 +118,26 @@ export function useMail() {
|
||||
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes, options)
|
||||
}
|
||||
|
||||
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 (204) */
|
||||
async function deleteMessage(id: string, options?: MailRequestOptions): Promise<void> {
|
||||
await api.delete(`/messages/${segment(id)}`, options)
|
||||
// DELETE /messages/{id} 는 부르지 않는다 — 휴지통이면 확인 없이 영구 삭제하므로, 그사이 다른 창이 휴지통으로 옮긴 메일을
|
||||
// 지울 수 있다. 삭제는 휴지통으로 옮기기(updateMessages { mailboxId }), 영구 삭제는 휴지통에서 확인한 뒤 deleteMessages 다.
|
||||
|
||||
/**
|
||||
* 여러 통의 읽음 · 중요 표시 · 이동 (PATCH /messages) — ids 는 100개까지 (부르는 쪽이 나눠 보낸다).
|
||||
* 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등)
|
||||
*/
|
||||
function updateMessages(patch: MessagesPatch, options?: MailRequestOptions): Promise<MessagesPatched> {
|
||||
return api.patch<MessagesPatched, MessagesPatched>('/messages', patch, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통에서 영구 삭제 (POST /message-deletions) — 휴지통에 있는 메일만 다룬다. ids 는 100개까지.
|
||||
* 확인한 상태 그대로일 때만 지우고, 계속 어긋나면 아무것도 지우지 않고 503 MAIL_001
|
||||
*/
|
||||
function deleteMessages(
|
||||
request: MessageDeletionRequest,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<MessageDeletionResult> {
|
||||
return api.post<MessageDeletionResult, MessageDeletionResult>('/message-deletions', request, options)
|
||||
}
|
||||
|
||||
// ── 임시 저장 (계약 2절 "임시 저장") ─────────────────────
|
||||
@@ -218,10 +251,12 @@ export function useMail() {
|
||||
return {
|
||||
listMailboxes,
|
||||
getQuota,
|
||||
getMailState,
|
||||
listMessages,
|
||||
getMessage,
|
||||
updateMessage,
|
||||
deleteMessage,
|
||||
updateMessages,
|
||||
deleteMessages,
|
||||
sendMessage,
|
||||
uploadAttachment,
|
||||
attachmentDownloadUrl,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import type { Mailbox, MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { isDraftRole, mailboxOfMessage, whoColumnLabel } from '@/shared/utils/mail-list-row'
|
||||
import { isAllMailboxSearch, messageLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 보이는 목록의 행마다 필요한 값 — 분할 보기 행(MessageListItem)과 목록 보기 표(MessageTable)가 함께 쓴다.
|
||||
* - 메일함 목록 · 한 메일함 안의 검색: 모든 행이 그 메일함의 규칙(임시보관함 · 보낸편지함)을 따른다
|
||||
* - 전체 메일함 검색: 행마다 그 메일의 메일함 — 메일함 이름을 함께 보인다 (디자인 README "3단계 › 검색")
|
||||
* - 누르면 갈 곳: 임시 저장본은 쓰기 화면에서 이어 쓰고, 그 밖은 지금 목록 안의 메일 주소 (검색 결과면 /mail/search/<id>?…)
|
||||
*/
|
||||
export function useMailListRows() {
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 전체 메일함에서 찾은 결과 — 행마다 메일함이 다르다 */
|
||||
const mixed = computed<boolean>(() => isAllMailboxSearch(mailStore.listSource))
|
||||
/** 보낸 사람 칸 머리 — 보낸편지함 · 임시보관함(한 메일함일 때)은 받는 사람 */
|
||||
const whoHeader = computed<string>(() => whoColumnLabel(mailStore.currentMailbox?.role ?? null, mixed.value))
|
||||
|
||||
function mailboxOf(message: MessageSummary): Mailbox | null {
|
||||
return mixed.value ? mailboxOfMessage(message, mailStore.mailboxes) : mailStore.currentMailbox
|
||||
}
|
||||
|
||||
function roleOf(message: MessageSummary): MailboxRole | null {
|
||||
return mailboxOf(message)?.role ?? null
|
||||
}
|
||||
|
||||
/** 행에 보일 메일함 이름 — 전체 메일함 검색 결과에서만 */
|
||||
function mailboxNameOf(message: MessageSummary): string | null {
|
||||
return mixed.value ? (mailboxOf(message)?.name ?? null) : null
|
||||
}
|
||||
|
||||
function locationOf(message: MessageSummary): RouteLocationRaw {
|
||||
if (isDraftRole(roleOf(message))) return { name: 'mail-compose', query: { draft: message.id } }
|
||||
const source = mailStore.listSource
|
||||
return source ? messageLocationOf(source, message.id) : { name: 'mail' }
|
||||
}
|
||||
|
||||
return { mixed, whoHeader, roleOf, mailboxNameOf, locationOf }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
import { movedMessageText, purgedMessageText, restoredMessageText, type BulkOrigin } from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리") — 도구 줄과 읽기 화면의 「삭제」가 함께 쓴다.
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
|
||||
* - "메일 N통을 영구 삭제했습니다."
|
||||
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
|
||||
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
|
||||
* 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast)
|
||||
*/
|
||||
export function useMailMoveFeedback() {
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
async function undo(origins: readonly BulkOrigin[]): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins)
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
|
||||
}
|
||||
|
||||
function announceMove(result: BulkMoveResult, mailboxName: string) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done === 0 && skipped === 0) return
|
||||
const origins = result.origins
|
||||
toast.success(
|
||||
movedMessageText(done, mailboxName, skipped),
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins) } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function announcePurge(result: MailBulkResult) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped))
|
||||
}
|
||||
|
||||
return { announceMove, announcePurge }
|
||||
}
|
||||
@@ -1,34 +1,55 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
listLocationOf,
|
||||
messageLocationOf,
|
||||
type MailListSource,
|
||||
} from '@/shared/utils/mail-list-source'
|
||||
|
||||
/**
|
||||
* 메일 화면 사이 이동 규칙 — 브라우저 기록(history)을 깔끔하게 유지한다.
|
||||
* - 이전·다음 메일은 기록을 바꿔 끼운다: 몇 통을 넘겨 봐도 "뒤로"는 목록으로 간다
|
||||
* - "목록으로"는 바로 앞 기록이 그 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다
|
||||
* - 없는 주소(404 RES_001)에서 다른 메일함으로 빠져나갈 때도 바꿔 끼운다: 뒤로 가기로 그 주소에 다시 오지 않게
|
||||
* 목록은 메일함 또는 검색 결과다 (mail-list-source.ts) — 검색 결과에서 연 메일은 /mail/search/<메일 id>?q=…&in=…
|
||||
*/
|
||||
export function useMailNavigation() {
|
||||
const router = useRouter()
|
||||
|
||||
function mailboxLocation(mailboxId: string): RouteLocationRaw {
|
||||
return { name: 'mail-mailbox', params: { mailboxId } }
|
||||
return listLocationOf({ kind: 'mailbox', mailboxId })
|
||||
}
|
||||
|
||||
function messageLocation(mailboxId: string, messageId: string): RouteLocationRaw {
|
||||
return { name: 'mail-message', params: { mailboxId, messageId } }
|
||||
return messageLocationOf({ kind: 'mailbox', mailboxId }, messageId)
|
||||
}
|
||||
|
||||
async function backToList(mailboxId: string): Promise<void> {
|
||||
const listPath = router.resolve(mailboxLocation(mailboxId)).fullPath
|
||||
if (router.options.history.state.back === listPath) {
|
||||
/** 바로 앞 기록이 그곳이면 뒤로 가기, 아니면 바꿔 끼운다 (기록을 쌓지 않는다) */
|
||||
async function backOrReplace(location: RouteLocationRaw): Promise<void> {
|
||||
const path = router.resolve(location).fullPath
|
||||
if (router.options.history.state.back === path) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
await router.replace(mailboxLocation(mailboxId))
|
||||
await router.replace(location)
|
||||
}
|
||||
|
||||
/** 목록으로 — 메일함 목록 또는 검색 결과 */
|
||||
async function backToSourceList(source: MailListSource): Promise<void> {
|
||||
await backOrReplace(listLocationOf(source))
|
||||
}
|
||||
|
||||
async function backToList(mailboxId: string): Promise<void> {
|
||||
await backToSourceList({ kind: 'mailbox', mailboxId })
|
||||
}
|
||||
|
||||
async function showSiblingIn(source: MailListSource, messageId: string): Promise<void> {
|
||||
await router.replace(messageLocationOf(source, messageId))
|
||||
}
|
||||
|
||||
async function showSibling(mailboxId: string, messageId: string): Promise<void> {
|
||||
await router.replace(messageLocation(mailboxId, messageId))
|
||||
await showSiblingIn({ kind: 'mailbox', mailboxId }, messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,5 +60,14 @@ export function useMailNavigation() {
|
||||
await router.replace(mailboxId ? mailboxLocation(mailboxId) : { name: 'mail' })
|
||||
}
|
||||
|
||||
return { mailboxLocation, messageLocation, backToList, showSibling, replaceWithMailbox }
|
||||
return {
|
||||
mailboxLocation,
|
||||
messageLocation,
|
||||
backOrReplace,
|
||||
backToList,
|
||||
backToSourceList,
|
||||
showSibling,
|
||||
showSiblingIn,
|
||||
replaceWithMailbox,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { getErrorMessage, notifyApiError } from './useApi'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import {
|
||||
exceedsSearchWords,
|
||||
isSearchRouteName,
|
||||
listLocationOf,
|
||||
normalizeSearchText,
|
||||
scopeMailboxIdOf,
|
||||
searchSourceFromQuery,
|
||||
type SearchListSource,
|
||||
} from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore, type SearchOrigin } from '@/stores/mail.store'
|
||||
|
||||
/** 메일함 화면(목록 · 연 메일)의 라우트 */
|
||||
const MAILBOX_ROUTES: readonly string[] = ['mail-mailbox', 'mail-message']
|
||||
|
||||
function paramOf(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 화면 오가기 (계약 2절 "검색" · 디자인 README "3단계 › 검색")
|
||||
* - 상단 검색 칸의 Enter: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서), 검색 화면에서는 지금 범위 그대로
|
||||
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다
|
||||
* - ✕ · Esc · "검색 닫기": 검색 전 메일함(화면)으로 — 바로 앞 기록이 그곳이면 뒤로 가기 (뒤로 가기: 메일 → 결과 → 검색 전 메일함)
|
||||
*/
|
||||
export function useMailSearchNavigation() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const { backOrReplace } = useMailNavigation()
|
||||
|
||||
const isSearching = computed<boolean>(() => isSearchRouteName(route.name))
|
||||
/** 지금 주소의 검색 — 검색 화면이 아니거나 검색어가 없으면 null */
|
||||
const routeSearch = computed<SearchListSource | null>(() =>
|
||||
isSearching.value ? searchSourceFromQuery(route.query) : null,
|
||||
)
|
||||
|
||||
/** 검색을 시작한 메일함 — 범위 줄의 "그 메일함". 모르면(주소로 바로 들어옴) 지금 범위, 그것도 없으면 기본 메일함 */
|
||||
const originMailbox = computed<Mailbox | null>(() => {
|
||||
const id = mailStore.searchOrigin?.mailboxId ?? scopeMailboxIdOf(routeSearch.value)
|
||||
const known = id ? mailStore.mailboxes.find((mailbox) => mailbox.id === id) : undefined
|
||||
return known ?? mailStore.defaultMailbox
|
||||
})
|
||||
|
||||
/** 지금 화면을 검색을 시작한 곳으로 — 쓰기 화면처럼 메일함 화면이 아니면 마지막으로 보던 메일함 화면 */
|
||||
function originOfCurrentScreen(): SearchOrigin {
|
||||
if (MAILBOX_ROUTES.includes(String(route.name))) {
|
||||
return { mailboxId: paramOf(route.params.mailboxId), location: route.fullPath }
|
||||
}
|
||||
const last = mailStore.lastBrowseLocation ? router.resolve(mailStore.lastBrowseLocation) : null
|
||||
if (last && MAILBOX_ROUTES.includes(String(last.name))) {
|
||||
return { mailboxId: paramOf(last.params.mailboxId), location: last.fullPath }
|
||||
}
|
||||
// 마지막으로 본 것이 검색 결과였다 — 그 검색을 시작한 곳
|
||||
return mailStore.searchOrigin ?? { mailboxId: mailStore.defaultMailbox?.id ?? null, location: null }
|
||||
}
|
||||
|
||||
/** 검색을 닫으면 갈 곳 */
|
||||
function returnLocation(): RouteLocationRaw {
|
||||
const saved = mailStore.searchOrigin?.location
|
||||
if (saved) return saved
|
||||
const id = originMailbox.value?.id
|
||||
return id ? { name: 'mail-mailbox', params: { mailboxId: id } } : { name: 'mail' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter — 빈 검색어는 아무것도 하지 않는다. 같은 검색어로 다시 누르면 보이는 채로 다시 찾는다.
|
||||
* 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 칸에 친 글자는 그대로 둔다
|
||||
*/
|
||||
async function startSearch(text: string): Promise<void> {
|
||||
const q = normalizeSearchText(text)
|
||||
if (!q) return
|
||||
if (exceedsSearchWords(q)) {
|
||||
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
|
||||
return
|
||||
}
|
||||
const current = routeSearch.value
|
||||
if (current) {
|
||||
if (current.q === q) {
|
||||
await mailStore.openSearch(q, current.scope, { force: true })
|
||||
return
|
||||
}
|
||||
await router.replace(listLocationOf({ ...current, q }))
|
||||
return
|
||||
}
|
||||
const origin = originOfCurrentScreen()
|
||||
mailStore.rememberSearchOrigin(origin)
|
||||
await router.push(listLocationOf({ kind: 'search', q, scope: origin.mailboxId ?? SEARCH_SCOPE_ALL }))
|
||||
}
|
||||
|
||||
/** 범위 전환 — 결과에서 연 메일이 있었으면 결과로 돌아간다 */
|
||||
async function setScope(scope: string): Promise<void> {
|
||||
const current = routeSearch.value
|
||||
if (!current) return
|
||||
if (current.scope === scope && route.name !== 'mail-search-message') return
|
||||
await router.replace(listLocationOf({ ...current, scope }))
|
||||
}
|
||||
|
||||
async function closeSearch(): Promise<void> {
|
||||
await backOrReplace(returnLocation())
|
||||
}
|
||||
|
||||
return { isSearching, routeSearch, originMailbox, startSearch, setScope, closeSearch }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
|
||||
// 새 메일 확인 (계약 2절 "새 메일 확인") — 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
|
||||
|
||||
describe('useMailStatePolling (새 메일 확인)', () => {
|
||||
let scope: EffectScope
|
||||
let clock: ManualClock
|
||||
let visibility: DocumentVisibilityState
|
||||
let states: MockResult[]
|
||||
let requests: MockRequest[]
|
||||
const onChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
scope = effectScope()
|
||||
clock = createManualClock()
|
||||
visibility = 'visible'
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
|
||||
onChange.mockReset()
|
||||
states = []
|
||||
requests = installMockApi(() => states.shift() ?? ok({ state: 'same' }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
Reflect.deleteProperty(document, 'visibilityState')
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function start() {
|
||||
scope.run(() => useMailStatePolling({ onChange, clock }))
|
||||
}
|
||||
|
||||
function setVisibility(next: DocumentVisibilityState) {
|
||||
visibility = next
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
const asked = () => requests.filter((request) => request.url === '/mail-state').length
|
||||
|
||||
async function tick(ms: number) {
|
||||
clock.advance(ms)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('바로 한 번 물어 기준을 잡고(알리지 않는다), 1분마다 다시 묻다가 값이 바뀌면 알린다', async () => {
|
||||
states = [ok({ state: 's1' }), ok({ state: 's1' }), ok({ state: 's2' })]
|
||||
start()
|
||||
await flushPromises()
|
||||
|
||||
expect(asked()).toBe(1)
|
||||
expect(requests[0]?.config.silent).toBe(true)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS - 1)
|
||||
expect(asked()).toBe(1)
|
||||
await tick(1)
|
||||
expect(asked()).toBe(2)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(asked()).toBe(3)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('화면이 안 보이는 동안은 묻지 않는다 — 돌아왔을 때 마지막으로 물은 지 10초가 지났으면 바로 한 번', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
setVisibility('hidden')
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 3)
|
||||
expect(asked()).toBe(1)
|
||||
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(2)
|
||||
})
|
||||
|
||||
it('돌아왔을 때 마지막으로 물은 지 10초가 안 됐으면 원래 차례(1분)에 묻는다', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
await tick(MAIL_STATE_RECHECK_GAP_MS - 4_000)
|
||||
setVisibility('hidden')
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS - (MAIL_STATE_RECHECK_GAP_MS - 4_000) - 1)
|
||||
expect(asked()).toBe(1)
|
||||
await tick(1)
|
||||
expect(asked()).toBe(2)
|
||||
})
|
||||
|
||||
it('한 번에 하나씩 — 묻는 중에 탭으로 돌아와도 또 묻지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
requests = installMockApi(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
start()
|
||||
await flushPromises()
|
||||
await tick(MAIL_STATE_RECHECK_GAP_MS * 2)
|
||||
setVisibility('hidden')
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
|
||||
expect(asked()).toBe(1)
|
||||
release(ok({ state: 's1' }))
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
})
|
||||
|
||||
it('실패(MAIL_001 · 네트워크)는 알리지 않고 다음 차례에 다시 묻는다 — 지난 값은 그대로 기준이다', async () => {
|
||||
states = [ok({ state: 's1' }), fail(503, 'MAIL_001'), 'network', ok({ state: 's1' }), ok({ state: 's2' })]
|
||||
start()
|
||||
await flushPromises()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(asked()).toBe(4)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('로그인하지 않았으면 묻지 않는다 — 세션이 끝나면 그만 묻는다', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
useAuthStore().clearAuth()
|
||||
await flushPromises()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 2)
|
||||
expect(asked()).toBe(1)
|
||||
})
|
||||
|
||||
it('화면이 사라지면(scope 종료) 그만 묻는다', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
scope.stop()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 2)
|
||||
setVisibility('hidden')
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { onScopeDispose, watch } from 'vue'
|
||||
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import { useMail } from './useMail'
|
||||
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일")
|
||||
// - 로그인한 동안, 화면이 보이는 동안(visibilityState === 'visible')만 60초마다 GET /mail-state 를 묻는다
|
||||
// - 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번 (안 지났으면 원래 차례에)
|
||||
// - 한 번에 하나씩 — 묻는 중이면 새로 묻지 않는다
|
||||
// - 처음 받은 값은 기준으로만 쓰고, 값이 바뀌면 onChange 를 부른다 (메일함 개수 · 보이는 목록 맞추기는 부르는 쪽이)
|
||||
// - 실패(MAIL_001 · 네트워크)는 알리지 않고(silent) 다음 차례에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 탄다
|
||||
// 실시간 푸시가 아니라 1분 확인이다 — 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 (계약 같은 절)
|
||||
|
||||
export interface MailStatePollingOptions {
|
||||
/** 지난 값과 달라졌을 때 */
|
||||
onChange: () => void
|
||||
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
export interface MailStatePollingHandle {
|
||||
/** 그만 묻는다 — 화면이 사라지면 저절로 부른다 */
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
function isPageVisible(): boolean {
|
||||
return typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||
}
|
||||
|
||||
export function useMailStatePolling(options: MailStatePollingOptions): MailStatePollingHandle {
|
||||
const { getMailState } = useMail()
|
||||
const authStore = useAuthStore()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
/** 지난 값 — 처음 받은 값은 기준으로만 쓴다 */
|
||||
let baseline: string | null = null
|
||||
let lastAskedAt: number | null = null
|
||||
let timer: ClockTimer | null = null
|
||||
let inFlight = false
|
||||
let stopped = false
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
function schedule(delay: number) {
|
||||
cancelTimer()
|
||||
if (stopped || !isPageVisible() || !authStore.isLoggedIn) return
|
||||
timer = clock.setTimeout(
|
||||
() => {
|
||||
timer = null
|
||||
void ask()
|
||||
},
|
||||
Math.max(0, delay),
|
||||
)
|
||||
}
|
||||
|
||||
async function ask(): Promise<void> {
|
||||
if (stopped || inFlight || !isPageVisible() || !authStore.isLoggedIn) return
|
||||
inFlight = true
|
||||
lastAskedAt = clock.now()
|
||||
const session = authStore.generation
|
||||
try {
|
||||
const { state } = await getMailState({ silent: true })
|
||||
// 묻는 사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면 버린다
|
||||
if (stopped || authStore.generation !== session) return
|
||||
const changed = baseline !== null && state !== baseline
|
||||
baseline = state
|
||||
if (changed) options.onChange()
|
||||
} catch {
|
||||
// 알리지 않는다 — 다음 차례에 다시 묻는다
|
||||
} finally {
|
||||
inFlight = false
|
||||
schedule(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (stopped) return
|
||||
if (!isPageVisible()) {
|
||||
cancelTimer()
|
||||
return
|
||||
}
|
||||
if (inFlight) return
|
||||
const elapsed = lastAskedAt === null ? Number.POSITIVE_INFINITY : clock.now() - lastAskedAt
|
||||
if (elapsed >= MAIL_STATE_RECHECK_GAP_MS) void ask()
|
||||
else schedule(MAIL_STATE_POLL_INTERVAL_MS - elapsed)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopped = true
|
||||
cancelTimer()
|
||||
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
// 세션이 바뀌면(다시 로그인 등) 지난 값을 버리고 새로 기준을 잡는다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
baseline = null
|
||||
if (authStore.isLoggedIn) void ask()
|
||||
else cancelTimer()
|
||||
},
|
||||
)
|
||||
|
||||
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
// 바로 한 번 물어 기준을 잡는다 — 1분 뒤에 처음 물으면 그 사이에 온 메일은 2분 뒤에야 알아챈다
|
||||
void ask()
|
||||
onScopeDispose(stop)
|
||||
|
||||
return { stop }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { defineComponent, h, type FunctionalComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { documentTitle } from '@/shared/utils/document-title'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
|
||||
// 탭 제목 — 받은편지함에 안 읽은 메일이 있으면 "(N) " (디자인 README "3단계 › 새 메일" · 디자인 원본 ListStates)
|
||||
|
||||
const Empty: FunctionalComponent = () => h('div')
|
||||
|
||||
/** 메일 모듈 레이아웃(MailLayout) 대신 — 이 composable 만 부른다 */
|
||||
const TitleHost = defineComponent({
|
||||
setup() {
|
||||
useMailTabTitle()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
function inbox(unreadCount: number): Mailbox {
|
||||
return { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 9, unreadCount }
|
||||
}
|
||||
|
||||
describe('useMailTabTitle (탭 제목)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
async function mountHost() {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/mail/compose', name: 'mail-compose', component: Empty, meta: { title: '메일 쓰기' } },
|
||||
{ path: '/mail/:mailboxId', name: 'mail-mailbox', component: Empty, meta: { title: '메일' } },
|
||||
{ path: '/settings', name: 'settings', component: Empty, meta: { title: '설정' } },
|
||||
],
|
||||
})
|
||||
// 앱 라우터와 같다 — 이동할 때마다 기본 제목을 쓴다
|
||||
router.afterEach((to) => {
|
||||
document.title = documentTitle(to.meta.title)
|
||||
})
|
||||
await router.push('/mail/inbox')
|
||||
await router.isReady()
|
||||
mount(TitleHost, { global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return router
|
||||
}
|
||||
|
||||
it('"(3) 메일 · ARA SYSTEM" — 모두 읽으면 "메일 · ARA SYSTEM"', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
await mountHost()
|
||||
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
|
||||
|
||||
useMailStore().mailboxes = [inbox(0)]
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('쓰기 화면에서도 — "(3) 메일 쓰기 · ARA SYSTEM", 이동이 기본 제목을 써도 다시 붙인다', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
const router = await mountHost()
|
||||
|
||||
await router.push('/mail/compose')
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('(3) 메일 쓰기 · ARA SYSTEM')
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('메일 모듈 밖에서는 붙이지 않는다', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
const router = await mountHost()
|
||||
|
||||
await router.push('/settings')
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('설정 · ARA SYSTEM')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { documentTitle } from '@/shared/utils/document-title'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 메일 모듈의 라우트 — 이름이 모두 mail 로 시작한다 (router/index.ts) */
|
||||
function isMailRouteName(name: unknown): boolean {
|
||||
return typeof name === 'string' && (name === 'mail' || name.startsWith('mail-'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 화면의 탭 제목 — 받은편지함에 안 읽은 메일이 있으면 앞에 "(N) " ("(3) 메일 · ARA SYSTEM" · "(3) 메일 쓰기 · ARA SYSTEM").
|
||||
* 디자인 README "3단계 › 새 메일". 이동할 때마다 router.afterEach 가 기본 제목을 쓰므로, 이동이 끝난 뒤(post)에 다시 맞춘다.
|
||||
* 메일 모듈 레이아웃(MailLayout)에서 부른다 — 다른 모듈로 가면 그 화면의 제목이 그대로 남는다
|
||||
*/
|
||||
export function useMailTabTitle() {
|
||||
const route = useRoute()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
watch(
|
||||
[() => route.fullPath, () => route.meta.title, () => mailStore.inboxUnreadCount],
|
||||
() => {
|
||||
if (!isMailRouteName(route.name)) return
|
||||
document.title = documentTitle(route.meta.title, mailStore.inboxUnreadCount)
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · 계약 2절 "여러 통 한꺼번에")
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함(mailboxIds)으로 정한다 — 검색 결과 · 다른 창에서 옮긴 메일도 맞게 (deletesPermanently)
|
||||
// · 휴지통 목록(휴지통 안의 검색 포함)에서는 휴지통에 든 메일이면 「영구 삭제」 (지금까지와 같다)
|
||||
// · 그 밖의 목록에서는 휴지통에만 든 메일만 「영구 삭제」 — 휴지통과 다른 메일함에 함께 든 메일(다른 메일 프로그램이 복사)은
|
||||
// 영구 삭제가 휴지통에서만 빼고 끝나 목록 · 알림이 사실과 달라지므로 「삭제」(휴지통으로 옮기기)다
|
||||
// - 「삭제」: 휴지통으로 옮긴다(PATCH /messages { mailboxId: 휴지통 }) — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// - 「영구 삭제」: 확인 대화상자를 거친 뒤 POST /message-deletions — "메일 1통을 영구 삭제했습니다."
|
||||
// - DELETE /messages/{id} 는 쓰지 않는다 — 메일함 정보가 낡았으면(그사이 다른 창이 휴지통으로 옮겼으면) 확인 없이 영구 삭제하기 때문이다.
|
||||
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
|
||||
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
|
||||
|
||||
export interface MessageRemovalOutcome {
|
||||
/** 서버가 처리했고 지금 목록에서도 빠졌다 — 다음 메일(또는 목록)로 넘어간다 */
|
||||
removed: boolean
|
||||
/** 이어서 보여 줄 메일 — 다음 메일, 없으면 이전 메일 (목록 밖의 메일이었으면 null) */
|
||||
nextId: string | null
|
||||
}
|
||||
|
||||
const NOTHING: MessageRemovalOutcome = { removed: false, nextId: null }
|
||||
|
||||
export function useMessageRemoval() {
|
||||
const mailStore = useMailStore()
|
||||
const feedback = useMailMoveFeedback()
|
||||
|
||||
/**
|
||||
* 「영구 삭제」할 메일인가 (위 규칙) — 휴지통 메일함을 아직 모르면 아니라고 본다 (옮기기 쪽이 해롭지 않다)
|
||||
*/
|
||||
function deletesPermanently(message: Pick<MessageSummary, 'mailboxIds'>): boolean {
|
||||
const trashId = mailStore.mailboxByRole('trash')?.id
|
||||
if (!trashId || !message.mailboxIds.includes(trashId)) return false
|
||||
if (mailStore.currentMailbox?.role === 'trash') return true
|
||||
return message.mailboxIds.every((id) => id === trashId)
|
||||
}
|
||||
|
||||
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
|
||||
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
|
||||
return { removed, nextId: removed ? nextId : null }
|
||||
}
|
||||
|
||||
/** 휴지통으로 옮긴다 */
|
||||
async function moveToTrash(message: MessageSummary): Promise<MessageRemovalOutcome> {
|
||||
const trash = await mailStore.resolveMailboxByRole('trash')
|
||||
if ('error' in trash) {
|
||||
notifyApiError(trash.error)
|
||||
return NOTHING
|
||||
}
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.moveMessages([message], trash.mailbox.id)
|
||||
feedback.announceMove(result, trash.mailbox.name)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
|
||||
async function purge(message: MessageSummary): Promise<MessageRemovalOutcome> {
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.purgeMessages([message.id])
|
||||
feedback.announcePurge(result)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
return { deletesPermanently, moveToTrash, purge }
|
||||
}
|
||||
@@ -1,14 +1,30 @@
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore, type ToastAction } from '@/stores/toast.store'
|
||||
|
||||
/**
|
||||
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
|
||||
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
|
||||
* 그 자리의 단추(되돌리기 등 — run)는 알림을 띄운 세션에서만 움직인다: 그사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면
|
||||
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store)
|
||||
*/
|
||||
export function useToast() {
|
||||
const store = useToastStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/** 띄운 세션에서만 움직이는 단추 */
|
||||
function sessionBound(action: ToastAction): ToastAction {
|
||||
if (!('run' in action)) return action
|
||||
const session = authStore.generation
|
||||
return {
|
||||
label: action.label,
|
||||
run: () => {
|
||||
if (authStore.generation === session) action.run()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function success(message: string, action?: ToastAction): number {
|
||||
return store.show({ kind: 'success', message, action })
|
||||
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
|
||||
}
|
||||
|
||||
return { success, dismiss: store.dismiss }
|
||||
|
||||
Reference in New Issue
Block a user