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:
2026-10-27 23:56:09 +09:00
parent 090ee361a3
commit 192807f943
83 changed files with 7421 additions and 1157 deletions
@@ -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)
})
})
+7 -11
View File
@@ -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) })
}
})
}
+41 -6
View File
@@ -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 }
}
+38 -8
View File
@@ -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 }
}
+17 -1
View File
@@ -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 }