generated from ttipo/project-base
chore: 14단계 검색 색인을 앱 로그인 브랜치에 합친다
앱 새 메일 알림(푸시)의 서버 일을 앱 세션과 14단계 위에서 하려고 합친다. 겹친 곳은 두 곳이다. auth.module.ts 는 import 둘을 함께 두고, AuthService.login() 은 enrollAfterLogin(14단계)을 남긴 뒤 recordSignIn(앱 로그인)으로 기록한다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,9 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
|
||||
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
|
||||
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
|
||||
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
|
||||
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때.
|
||||
// 옮기기 · 삭제 · 스팸 알림은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 "4통"은 대화 전체라서 (2026-11-06 실사용 시험).
|
||||
// 휴지통의 영구 삭제도 확인 창 · 알림이 "휴지통에 있는 메일 2통을 영구 삭제할까요? · …했습니다." (2026-11-07 — conversationFrom)
|
||||
|
||||
export function useBulkMailActions() {
|
||||
const mailStore = useMailStore()
|
||||
@@ -22,16 +25,24 @@ export function useBulkMailActions() {
|
||||
/** 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다 (읽음 · 중요 표시는 막지 않는다) */
|
||||
const busy = ref<boolean>(false)
|
||||
|
||||
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 */
|
||||
const allFlagged = computed<boolean>(
|
||||
() => mailStore.checkedMessages.length > 0 && mailStore.checkedMessages.every((message) => message.isFlagged),
|
||||
)
|
||||
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 (대화 목록은 고른 줄이 모두 중요 표시가 있을 때) */
|
||||
const allFlagged = computed<boolean>(() => {
|
||||
const rows = mailStore.isConversationList ? mailStore.checkedConversations : mailStore.checkedMessages
|
||||
return rows.length > 0 && rows.every((row) => row.isFlagged)
|
||||
})
|
||||
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
|
||||
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
|
||||
/** 스팸메일함을 보는 중 — 「스팸 신고」가 「스팸 아님」이 된다 (스팸메일함 안의 검색 포함) */
|
||||
const inJunk = computed<boolean>(() => mailStore.currentMailbox?.role === 'junk')
|
||||
/** 스팸 단추가 옮길 곳 — 스팸 신고는 스팸메일함, 스팸 아님은 받은편지함. 그 메일함을 모르면 단추를 그리지 않는다 */
|
||||
const spamTarget = computed<Mailbox | null>(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk'))
|
||||
/**
|
||||
* 대화로 묶은 목록이면 지금 메일함의 이름 — 줄의 통 수는 대화 전체라 알림 · 영구 삭제 확인이 그 메일함의 메일임을 밝힌다
|
||||
* ("받은편지함에 있는 메일 2통을 …" · "휴지통에 있는 메일 2통을 영구 삭제할까요?"). 묶지 않은 목록이면 null (지금 글 그대로)
|
||||
*/
|
||||
const conversationFrom = computed<string | null>(() =>
|
||||
mailStore.isConversationList ? (mailStore.currentMailbox?.name ?? null) : null,
|
||||
)
|
||||
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
|
||||
const moveTargets = computed<Mailbox[]>(() =>
|
||||
mailStore.orderedMailboxes
|
||||
@@ -56,17 +67,19 @@ export function useBulkMailActions() {
|
||||
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
|
||||
if (mailStore.isListedMessage(openId)) return
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
|
||||
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
// 대화로 묶은 목록은 줄의 통 수가 대화 전체라 알림이 지금 메일함의 메일임을 밝힌다 — 시작할 때의 메일함 (기다리는 사이 목록이 바뀔 수 있다)
|
||||
const from = conversationFrom.value
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveChecked(mailbox.id, options)
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox.name)
|
||||
feedback.announceMove(result, mailbox.name, { from })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
@@ -98,11 +111,13 @@ export function useBulkMailActions() {
|
||||
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
|
||||
async function purge(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
// 옮기기와 같은 까닭 — 대화로 묶은 휴지통이면 "휴지통에 있는 메일 N통을 영구 삭제했습니다." (시작할 때의 메일함)
|
||||
const from = conversationFrom.value
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.purgeChecked()
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announcePurge(result)
|
||||
feedback.announcePurge(result, { from })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
@@ -114,6 +129,7 @@ export function useBulkMailActions() {
|
||||
inTrash,
|
||||
inJunk,
|
||||
spamTarget,
|
||||
conversationFrom,
|
||||
moveTargets,
|
||||
markRead,
|
||||
toggleFlagged,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { useToast } from './useToast'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import type { ConversationSummary, MailboxRole } from '@/shared/types/mail'
|
||||
import { conversationWho, isConversationHeld } from '@/shared/utils/mail-conversation-list'
|
||||
import { isDraftRole, isRowUnread, type RowWho } from '@/shared/utils/mail-list-row'
|
||||
import { messageLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 대화로 묶은 메일함의 줄마다 필요한 값 (14단계 — 디자인 원본 ListThreads14) — 분할 보기 줄(ConversationListItem)과 목록 보기 표(ConversationTableRow)가 함께 쓴다.
|
||||
* - 이름 자리: 참여자(내 주소는 "나" — 끝에), 보낸편지함 · 임시보관함에서 나뿐이면 "받는 사람: …" (mail-conversation-list.ts 의 conversationWho)
|
||||
* - 안 읽음: 하나라도 안 읽었으면 굵게 — 임시보관함은 메일 목록처럼 굵게 하지 않는다
|
||||
* - 누르면 갈 곳: 그 메일함의 마지막 메일 — 임시보관함이면 쓰기 화면에서 이어 쓴다(메일 목록과 같다)
|
||||
* - 열린 메일이 든 줄: 마지막 메일이거나 줄에 든 메일([이 메일만 열기]로 연 더 오래된 메일)
|
||||
* - 별: 줄의 메일 모두(붙잡힌 메일은 빼고 — 스토어). 줄의 중요 표시가 붙잡힌 메일에만 있어 풀리지 않았으면 그 까닭을 알린다
|
||||
*/
|
||||
export function useConversationRows() {
|
||||
const mailStore = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** 목록의 메일함 역할 — 묶은 목록은 늘 메일함 하나다 */
|
||||
const role = computed<MailboxRole | null>(() => mailStore.currentMailbox?.role ?? null)
|
||||
/** 임시보관함 — 제목 없는 저장본을 흐린 글자로 */
|
||||
const isDraft = computed<boolean>(() => isDraftRole(role.value))
|
||||
|
||||
function whoOf(row: ConversationSummary, view: 'split' | 'table'): RowWho {
|
||||
return conversationWho(row, { myEmail: authStore.user?.email, role: role.value, view })
|
||||
}
|
||||
|
||||
function isUnread(row: ConversationSummary): boolean {
|
||||
return isRowUnread({ isRead: row.isRead }, role.value)
|
||||
}
|
||||
|
||||
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
|
||||
function isHeld(row: ConversationSummary): boolean {
|
||||
return isConversationHeld(row)
|
||||
}
|
||||
|
||||
function locationOf(row: ConversationSummary): RouteLocationRaw {
|
||||
if (isDraftRole(role.value)) return { name: 'mail-compose', query: { draft: row.latest.id } }
|
||||
const source = mailStore.listSource
|
||||
return source ? messageLocationOf(source, row.latest.id) : { name: 'mail' }
|
||||
}
|
||||
|
||||
function isSelected(row: ConversationSummary): boolean {
|
||||
const id = mailStore.selectedId
|
||||
return id !== null && (row.latest.id === id || row.messageIds.includes(id))
|
||||
}
|
||||
|
||||
async function toggleFlag(rowId: string): Promise<void> {
|
||||
const { heldFlagged } = await mailStore.toggleConversationFlag(rowId)
|
||||
if (heldFlagged) toast.error(MAIL_CONVERSATION_LIST_TEXT.heldFlagNote)
|
||||
}
|
||||
|
||||
return { role, isDraft, whoOf, isUnread, isHeld, locationOf, isSelected, toggleFlag }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { onScopeDispose, watch } from 'vue'
|
||||
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import { MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 읽기 화면의 "같은 대화" 칸을 언제 받을지 (14단계 — 계약 2절 "대화 — 14단계"). 칸(MessageConversation)이 시작하고, 칸이 사라지면 멈춘다
|
||||
// - 메일이 바뀌면 그 메일의 대화를 받는다(스토어의 loadConversation — 같은 대화의 다른 메일이면 보이던 줄을 둔 채)
|
||||
// - 그 메일의 대화를 이미 받아 두었으면(쓰기 화면에서 답장을 보내고 돌아옴 · 보기 방식을 바꿈) 보이는 채로 바로 다시 받는다
|
||||
// - 다시 받아 달라는 소식(conversationRefreshSeq — 메일함들의 통 수가 바뀌었을 때만 오른다, mail.store.ts)이 오면 다시 받는다 —
|
||||
// 마지막으로 받기가 끝난 지 MAIL_CONVERSATION_REFRESH_GAP_MS 가 지나지 않았으면 그때까지 모았다가 한 번에 (잦지 않게).
|
||||
// 끝난 때는 스토어가 실제로 물었을 때만 센다(conversationBusy 가 꺼질 때). 묻는 중에 온 소식은 끝난 뒤 한 번 더(그때부터 간격)
|
||||
// - 기다림은 간격보다 길지 않다 — 이 PC 시계가 뒤로 가도 오래 멈추지 않게
|
||||
// - 보호 화면을 쓸 수 있는 세션(canUseApp)일 때만 묻는다 — 로그인 전 · 제한된 세션(13단계)에는 묻지 않는다
|
||||
// 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts)
|
||||
|
||||
export interface ConversationSyncOptions {
|
||||
/** 읽기 화면에 연 메일 */
|
||||
messageId: () => string
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
export function useConversationSync(options: ConversationSyncOptions): void {
|
||||
const mailStore = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
/** 마지막으로 받기가 끝난 때 — 이 칸이 뜬 뒤 실제로 물은 것만 */
|
||||
let lastFinishedAt: number | null = null
|
||||
let timer: ClockTimer | null = null
|
||||
/** 묻는 중에 온 소식 — 끝난 뒤 한 번 더 */
|
||||
let pending = false
|
||||
let stopped = false
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
/** 지금 다시 받는다 — 묻는 중이면 끝난 뒤로 미룬다 */
|
||||
function refreshNow() {
|
||||
if (stopped || !authStore.canUseApp) return
|
||||
if (mailStore.conversationBusy) {
|
||||
pending = true
|
||||
return
|
||||
}
|
||||
void mailStore.refreshConversation()
|
||||
}
|
||||
|
||||
/**
|
||||
* 다시 받아 달라 — 묻는 중이면 끝난 뒤로 미루고, 간격이 지났으면 바로, 아니면 간격이 끝날 때 한 번 (그사이 온 요청은 함께).
|
||||
* 간격이 끝나면 다시 재지 않고 받는다 — 시계가 뒤로 가도 간격보다 오래 기다리지 않게
|
||||
*/
|
||||
function requestRefresh() {
|
||||
if (stopped || !authStore.canUseApp) return
|
||||
if (mailStore.conversationBusy) {
|
||||
pending = true
|
||||
return
|
||||
}
|
||||
if (timer !== null) return
|
||||
const wait =
|
||||
lastFinishedAt === null
|
||||
? 0
|
||||
: Math.min(MAIL_CONVERSATION_REFRESH_GAP_MS, lastFinishedAt + MAIL_CONVERSATION_REFRESH_GAP_MS - clock.now())
|
||||
if (wait <= 0) {
|
||||
refreshNow()
|
||||
return
|
||||
}
|
||||
timer = clock.setTimeout(() => {
|
||||
timer = null
|
||||
refreshNow()
|
||||
}, wait)
|
||||
}
|
||||
|
||||
// 받기가 끝날 때마다(받기 · 다시 받기 · [더 보기] · 붙잡힌 메일 다시 보기) 그때부터 간격을 센다 — 묻는 중에 온 소식은 그때 한 번 더
|
||||
watch(
|
||||
() => mailStore.conversationBusy,
|
||||
(busy, wasBusy) => {
|
||||
if (stopped || busy || !wasBusy) return
|
||||
lastFinishedAt = clock.now()
|
||||
if (!pending) return
|
||||
pending = false
|
||||
requestRefresh()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => options.messageId(), () => authStore.canUseApp],
|
||||
([messageId, usable]) => {
|
||||
cancelTimer()
|
||||
pending = false
|
||||
if (!usable) return
|
||||
if (mailStore.conversationMessageId === messageId && mailStore.conversationStatus === 'ready') {
|
||||
requestRefresh()
|
||||
return
|
||||
}
|
||||
void mailStore.loadConversation(messageId)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mailStore.conversationRefreshSeq,
|
||||
() => requestRefresh(),
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopped = true
|
||||
cancelTimer()
|
||||
})
|
||||
}
|
||||
@@ -336,7 +336,7 @@ describe('useLargeFileUploads', () => {
|
||||
|
||||
expect(uploads.items.value.map((item) => [item.name, item.phase, item.errorText])).toEqual([
|
||||
// 한도 글은 MB · GB 로 적는다 — 이 테스트의 12바이트 한도는 "0MB" 로 보인다
|
||||
['too-big.zip', 'rejected', '파일 하나는 0MB 까지 보낼 수 있습니다.'],
|
||||
['too-big.zip', 'rejected', '파일 하나는 0MB까지 보낼 수 있습니다.'],
|
||||
['first.zip', 'ready', null],
|
||||
['second.zip', 'rejected', '링크로 보내는 파일은 메일 한 통에 1개까지입니다.'],
|
||||
['setup.exe', 'rejected', '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.'],
|
||||
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
MAIL_SEND_TIMEOUT_MS,
|
||||
MAIL_UPLOAD_TIMEOUT_MS,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_TIMEOUT_MS } from '@/shared/constants/mail-conversation'
|
||||
import type {
|
||||
Attachment,
|
||||
ConversationListPage,
|
||||
ConversationListQuery,
|
||||
ConversationPage,
|
||||
Draft,
|
||||
DraftPayload,
|
||||
DraftSaved,
|
||||
@@ -32,6 +36,8 @@ import type {
|
||||
MessageDeletionRequest,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
MessageHighlightQuery,
|
||||
MessageListItem,
|
||||
MessageListQuery,
|
||||
MessageSummary,
|
||||
MessagesPatch,
|
||||
@@ -40,12 +46,15 @@ import type {
|
||||
RecipientSuggestionQuery,
|
||||
SaveSignaturePayload,
|
||||
ScheduledMessageCancel,
|
||||
SearchIndexState,
|
||||
SendMessagePayload,
|
||||
SentMessage,
|
||||
SignatureImage,
|
||||
UpdateMessagePayload,
|
||||
} from '@/shared/types/mail'
|
||||
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
|
||||
import { conversationPageOf } from '@/shared/utils/mail-conversation'
|
||||
import { conversationListPageOf } from '@/shared/utils/mail-conversation-list'
|
||||
import { pendingSendOf } from '@/shared/utils/pending-send'
|
||||
|
||||
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
|
||||
@@ -54,6 +63,11 @@ export type MailRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
|
||||
/** 메일 한 통 읽기의 옵션 — showImages 면 외부 이미지를 보이는 본문(`images=show` — 9단계 "외부 이미지 보기") */
|
||||
export interface GetMessageOptions extends MailRequestOptions {
|
||||
showImages?: boolean
|
||||
/**
|
||||
* 검색 결과에서 연 메일의 강조 쿼리 (14단계 — q · from · to · subject · body 만, mail-search.ts 의 messageHighlightQueryOf).
|
||||
* ⚠️ 읽기 화면(스토어의 열린 메일)만 넘긴다 — 답장 · 전달의 원문 · 인쇄는 넘기지 않는다(강조가 든 응답은 보이기만 한다 — 계약 2절)
|
||||
*/
|
||||
highlight?: MessageHighlightQuery | null
|
||||
}
|
||||
|
||||
export interface SendMessageOptions extends MailRequestOptions {
|
||||
@@ -225,31 +239,81 @@ export function useMail() {
|
||||
|
||||
/**
|
||||
* 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다.
|
||||
* 검색(q)은 mailboxId 가 있으면 그 메일함, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
|
||||
* 검색(q · 조건 — 14단계)은 mailboxId 가 있으면 그 메일함, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다.
|
||||
* 색인으로 찾은 검색은 hasNext 인데 항목이 적거나 0개일 수 있다 — 이어 묻는 것은 부르는 쪽(목록 스토어)이다
|
||||
*/
|
||||
function listMessages(
|
||||
query: MessageListQuery,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<PaginatedResult<MessageSummary>> {
|
||||
return api.get<PaginatedResult<MessageSummary>, PaginatedResult<MessageSummary>>('/messages', {
|
||||
): Promise<PaginatedResult<MessageListItem>> {
|
||||
return api.get<PaginatedResult<MessageListItem>, PaginatedResult<MessageListItem>>('/messages', {
|
||||
...options,
|
||||
params: query,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 지난 메일 색인의 상태 (14단계 — 계약 2절 "검색 색인") — 조회지만 상태 줄을 만들고 멈춤을 푼다(멱등).
|
||||
* 검색을 시작할 때 조용히 묻는다 — building 이면 결과 머리 아래 "검색 준비 중" 줄
|
||||
*/
|
||||
function getSearchIndex(options?: MailRequestOptions): Promise<SearchIndexState> {
|
||||
return api.get<SearchIndexState, SearchIndexState>('/search-index', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 조회만으로는 읽음 처리되지 않는다 — 읽음은 updateMessage 로 따로 보낸다.
|
||||
* showImages 면 `?images=show` — 외부 이미지를 중계 주소로 바꿔 넣은 본문(imagesShown: true). 주지 않으면 서버가 설정을 따른다
|
||||
* (늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 — 스팸메일함의 메일은 늘 지운다, 계약 2절 "외부 이미지 보기")
|
||||
* (늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 — 스팸메일함의 메일은 늘 지운다, 계약 2절 "외부 이미지 보기").
|
||||
* highlight 면 그 검색의 맞은 곳을 강조한 본문(searchHighlight · htmlBody 의 <mark> — 14단계)
|
||||
*/
|
||||
function getMessage(id: string, options: GetMessageOptions = {}): Promise<MessageDetail> {
|
||||
const { showImages, ...requestOptions } = options
|
||||
const { showImages, highlight, ...requestOptions } = options
|
||||
const params: Record<string, string> = {}
|
||||
if (showImages) params[MAIL_IMAGES_PARAM] = MAIL_IMAGES_SHOW
|
||||
for (const [name, value] of Object.entries(highlight ?? {})) {
|
||||
if (typeof value === 'string' && value !== '') params[name] = value
|
||||
}
|
||||
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, {
|
||||
...requestOptions,
|
||||
...(showImages ? { params: { [MAIL_IMAGES_PARAM]: MAIL_IMAGES_SHOW } } : {}),
|
||||
...(Object.keys(params).length > 0 ? { params } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 메일의 같은 대화 — 오래된 차례, 한 통뿐이면 그 메일 하나 (14단계 — 계약 2절 "대화 — 14단계").
|
||||
* hasNext 인데 항목이 적거나 0개인 쪽이 올 수 있다(엔진 방식) — 이어 묻는 것은 부르는 쪽(스토어의 conversation.ts)이다.
|
||||
* 커서는 그 메일에서만 통한다. 응답은 모양을 맞춰 돌려준다 — 틀리면 빈 대화(칸이 없다)
|
||||
*/
|
||||
async function getConversation(
|
||||
id: string,
|
||||
query: CursorPaginationQuery,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<ConversationPage> {
|
||||
const page = await api.get<unknown, unknown>(`/messages/${segment(id)}/conversation`, {
|
||||
timeout: MAIL_CONVERSATION_TIMEOUT_MS,
|
||||
...options,
|
||||
params: query,
|
||||
})
|
||||
return conversationPageOf(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 메일함을 대화로 묶은 목록 — 한 줄이 대화 하나, 그 메일함 안 마지막 받은 시각 최신부터 (14단계 — 계약 2절 "대화로 묶어 보기").
|
||||
* 커서는 그 메일함 · 그 기준에서만 통한다. 엔진 방식은 한 요청이 15초까지 걸릴 수 있어 같은 대화와 같은 시간 한도를 쓴다.
|
||||
* 응답은 모양을 맞춰 돌려준다 — 모양이 틀린 줄은 빼고, 마지막 메일은 늘 messageIds 에 든다
|
||||
*/
|
||||
async function listConversations(
|
||||
query: ConversationListQuery,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<ConversationListPage> {
|
||||
const page = await api.get<unknown, unknown>('/conversations', {
|
||||
timeout: MAIL_CONVERSATION_TIMEOUT_MS,
|
||||
...options,
|
||||
params: query,
|
||||
})
|
||||
return conversationListPageOf(page)
|
||||
}
|
||||
|
||||
function updateMessage(
|
||||
id: string,
|
||||
changes: UpdateMessagePayload,
|
||||
@@ -453,7 +517,10 @@ export function useMail() {
|
||||
downloadMessageSource,
|
||||
getMailState,
|
||||
listMessages,
|
||||
getSearchIndex,
|
||||
getMessage,
|
||||
getConversation,
|
||||
listConversations,
|
||||
updateMessage,
|
||||
updateMessages,
|
||||
deleteMessages,
|
||||
|
||||
@@ -6,15 +6,18 @@ import {
|
||||
purgedMessageText,
|
||||
restoredMessageText,
|
||||
type BulkOrigin,
|
||||
type BulkTextOptions,
|
||||
} from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문") — 도구 줄과 읽기 화면의 「삭제」 · 「스팸 신고」가 함께 쓴다.
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
|
||||
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 받은편지함으로 옮겼습니다 (스팸 아님)."
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고, 한 메일함으로 되돌렸으면 "메일 N통을 X으로 되돌렸습니다.",
|
||||
* 여러 곳이면 "메일 N통을 되돌렸습니다.")
|
||||
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다."
|
||||
* — 스팸 신고 · 스팸 아님의 되돌리기는 원래 메일함으로 옮기며 표시도 되돌린다(보낸 값의 반대)
|
||||
* - "메일 N통을 영구 삭제했습니다."
|
||||
* - 대화로 묶은 목록에서 줄에 한 일(14단계)은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 통 수는 대화 전체라서 (options.from)
|
||||
* - "메일 N통을 영구 삭제했습니다." — 대화로 묶은 휴지통에서 줄에 한 일이면 "휴지통에 있는 메일 N통을 영구 삭제했습니다." (같은 까닭 · options.from)
|
||||
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
|
||||
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
|
||||
* 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast)
|
||||
@@ -23,31 +26,42 @@ export function useMailMoveFeedback() {
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** 되돌린 메일함의 이름 — 한 메일함으로 되돌릴 때만(여러 곳이면 null), 메일함 목록에 없으면 null */
|
||||
function restoredPlaceOf(origins: readonly BulkOrigin[]): string | null {
|
||||
if (origins.length !== 1) return null
|
||||
const mailboxId = origins[0]?.mailboxId
|
||||
return mailStore.mailboxes.find((mailbox) => mailbox.id === mailboxId)?.name ?? null
|
||||
}
|
||||
|
||||
async function undo(origins: readonly BulkOrigin[], options: MoveOptions): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins, options)
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped, restoredPlaceOf(origins)))
|
||||
}
|
||||
|
||||
function announceMove(result: BulkMoveResult, mailboxName: string) {
|
||||
/** textOptions.from — 대화로 묶은 목록에서 줄에 한 일이면 그 목록의 메일함 이름 ("받은편지함에 있는 메일 2통을 …") */
|
||||
function announceMove(result: BulkMoveResult, mailboxName: string, textOptions: BulkTextOptions = {}) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done === 0 && skipped === 0) return
|
||||
const origins = result.origins
|
||||
const undoOptions: MoveOptions = result.isJunk === undefined ? {} : { isJunk: !result.isJunk }
|
||||
const text =
|
||||
result.isJunk === false ? notSpamMovedText(done, mailboxName, skipped) : movedMessageText(done, mailboxName, skipped)
|
||||
result.isJunk === false
|
||||
? notSpamMovedText(done, mailboxName, skipped, textOptions)
|
||||
: movedMessageText(done, mailboxName, skipped, textOptions)
|
||||
toast.success(
|
||||
text,
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins, undoOptions) } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function announcePurge(result: MailBulkResult) {
|
||||
/** textOptions.from — 대화로 묶은 휴지통에서 줄에 한 일이면 그 메일함 이름 ("휴지통에 있는 메일 2통을 영구 삭제했습니다.") */
|
||||
function announcePurge(result: MailBulkResult, textOptions: BulkTextOptions = {}) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped))
|
||||
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped, textOptions))
|
||||
}
|
||||
|
||||
return { announceMove, announcePurge }
|
||||
|
||||
@@ -6,14 +6,21 @@ import { useMailNavigation } from './useMailNavigation'
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import {
|
||||
exceedsSearchWords,
|
||||
isSearchRouteName,
|
||||
listLocationOf,
|
||||
normalizeSearchText,
|
||||
sameListSource,
|
||||
scopeMailboxIdOf,
|
||||
searchSourceFromQuery,
|
||||
searchSourceOf,
|
||||
type SearchListSource,
|
||||
} from '@/shared/utils/mail-list-source'
|
||||
import {
|
||||
exceedsCombinedSearchWords,
|
||||
hasSearchConditions,
|
||||
normalizeSearchConditions,
|
||||
type SearchConditions,
|
||||
} from '@/shared/utils/mail-search'
|
||||
import { useMailStore, type SearchOrigin } from '@/stores/mail.store'
|
||||
|
||||
/** 메일함 화면(목록 · 연 메일)의 라우트 */
|
||||
@@ -24,9 +31,12 @@ function paramOf(value: unknown): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 화면 오가기 (계약 2절 "검색" · 디자인 README "3단계 › 검색")
|
||||
* - 상단 검색 칸의 Enter: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서), 검색 화면에서는 지금 범위 그대로
|
||||
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다
|
||||
* 검색 화면 오가기 (계약 2절 "검색" · "검색 — 14단계" · 디자인 README "3단계 › 검색" · "14단계 › 검색 · 조건 검색 · 강조")
|
||||
* - 상단 검색 칸의 Enter · 「조건으로 찾기」의 [찾기]: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서),
|
||||
* 검색 화면에서는 지금 범위 그대로. 검색 칸이 비어도 조건만으로 찾는다. Enter 는 걸려 있는 조건을 그대로 둔다.
|
||||
* 검색어도 패널의 조건도 없는 [찾기]는 조건이 걸린 검색이면 검색을 닫는다(조건 줄의 [조건 지우기]와 같다)
|
||||
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다 (조건은 그대로)
|
||||
* - 조건 줄의 [조건 지우기]: 조건만 빼고 다시 찾는다 — 검색어도 없으면 검색을 닫는다
|
||||
* - ✕ · Esc · "검색 닫기": 검색 전 메일함(화면)으로 — 바로 앞 기록이 그곳이면 뒤로 가기 (뒤로 가기: 메일 → 결과 → 검색 전 메일함)
|
||||
*/
|
||||
export function useMailSearchNavigation() {
|
||||
@@ -70,28 +80,50 @@ export function useMailSearchNavigation() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter — 빈 검색어는 아무것도 하지 않는다. 같은 검색어로 다시 누르면 보이는 채로 다시 찾는다.
|
||||
* 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 칸에 친 글자는 그대로 둔다
|
||||
* Enter · [찾기] — 검색어도 조건도 없으면 아무것도 하지 않는다. 같은 검색으로 다시 누르면 보이는 채로 다시 찾는다.
|
||||
* conditions 를 주면(「조건으로 찾기」) 그 조건으로, 주지 않으면(검색 칸의 Enter) 걸려 있는 조건 그대로 찾는다.
|
||||
* 조건이 걸린 검색에서 패널의 칸을 모두 비우고 검색어도 없이 [찾기]를 누르면 조건 줄의 [조건 지우기]처럼 검색을 닫는다
|
||||
* (2026-11-07 — 패널의 [조건 지우기]가 칸만 비우게 되어, 조건만 걸린 검색은 [찾기]로도 조건을 뺄 수 있게).
|
||||
* 검색어와 조건 넷을 합친 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 친 글자는 그대로 둔다.
|
||||
* 찾았으면(검색을 닫았으면) true (패널이 닫힌다)
|
||||
*/
|
||||
async function startSearch(text: string): Promise<void> {
|
||||
async function startSearch(text: string, conditions?: Partial<SearchConditions> | null): Promise<boolean> {
|
||||
const q = normalizeSearchText(text)
|
||||
if (!q) return
|
||||
if (exceedsSearchWords(q)) {
|
||||
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
|
||||
return
|
||||
}
|
||||
const current = routeSearch.value
|
||||
const nextConditions = conditions === undefined ? (current?.conditions ?? null) : normalizeSearchConditions(conditions)
|
||||
if (!q && !hasSearchConditions(nextConditions)) {
|
||||
if (!current || !hasSearchConditions(current.conditions)) return false
|
||||
await closeSearch()
|
||||
return true
|
||||
}
|
||||
if (exceedsCombinedSearchWords(q, nextConditions)) {
|
||||
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
|
||||
return false
|
||||
}
|
||||
if (current) {
|
||||
if (current.q === q) {
|
||||
await mailStore.openSearch(q, current.scope, { force: true })
|
||||
return
|
||||
const next = searchSourceOf(q, current.scope, nextConditions)
|
||||
if (sameListSource(current, next)) {
|
||||
await mailStore.openSearch(q, current.scope, { force: true, conditions: next.conditions })
|
||||
return true
|
||||
}
|
||||
await router.replace(listLocationOf({ ...current, q }))
|
||||
return
|
||||
await router.replace(listLocationOf(next))
|
||||
return true
|
||||
}
|
||||
const origin = originOfCurrentScreen()
|
||||
mailStore.rememberSearchOrigin(origin)
|
||||
await router.push(listLocationOf({ kind: 'search', q, scope: origin.mailboxId ?? SEARCH_SCOPE_ALL }))
|
||||
await router.push(listLocationOf(searchSourceOf(q, origin.mailboxId ?? SEARCH_SCOPE_ALL, nextConditions)))
|
||||
return true
|
||||
}
|
||||
|
||||
/** 조건 줄의 [조건 지우기] — 조건만 빼고 같은 검색어 · 범위로 다시 찾는다. 검색어가 없으면 검색을 닫는다 */
|
||||
async function clearConditions(): Promise<void> {
|
||||
const current = routeSearch.value
|
||||
if (!current || !hasSearchConditions(current.conditions)) return
|
||||
if (!current.q) {
|
||||
await closeSearch()
|
||||
return
|
||||
}
|
||||
await router.replace(listLocationOf(searchSourceOf(current.q, current.scope)))
|
||||
}
|
||||
|
||||
/** 범위 전환 — 결과에서 연 메일이 있었으면 결과로 돌아간다 */
|
||||
@@ -106,5 +138,5 @@ export function useMailSearchNavigation() {
|
||||
await backOrReplace(returnLocation())
|
||||
}
|
||||
|
||||
return { isSearching, routeSearch, originMailbox, startSearch, setScope, closeSearch }
|
||||
return { isSearching, routeSearch, originMailbox, startSearch, clearConditions, setScope, closeSearch }
|
||||
}
|
||||
|
||||
@@ -9,20 +9,35 @@ export type MailSettingsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'si
|
||||
/** 경로에 넣는 값은 항상 인코딩한다 — 도메인 차단 "@spam.example" 은 "%40spam.example" */
|
||||
const segment = (value: string) => encodeURIComponent(value)
|
||||
|
||||
/** GET · PATCH /mail-settings 가 실제로 주는 모양 — 14단계 앞의 서버는 groupByConversation 이 없다 */
|
||||
type MailSettingsReply = Omit<MailSettings, 'groupByConversation'> & { groupByConversation?: unknown }
|
||||
|
||||
/** 설정 응답 → 화면의 값. groupByConversation 이 불리언 true 가 아니면 묶지 않는 것으로 본다 (처음 값 — 계약 2절 "보내기 · 읽기 설정") */
|
||||
function mailSettingsOf(reply: MailSettingsReply): MailSettings {
|
||||
// 응답을 바꾼 게이트웨이 등으로 객체가 아닐 수도 있다 — 그때도 던지지 않고 묶지 않는 것으로 본다
|
||||
const value: unknown = reply
|
||||
const grouped =
|
||||
typeof value === 'object' && value !== null && (value as { groupByConversation?: unknown }).groupByConversation === true
|
||||
return { ...reply, groupByConversation: grouped }
|
||||
}
|
||||
|
||||
// 보내기 · 읽기 설정 · 보낸 사람 차단 API Composable — docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" · "보낸 사람 차단" (9단계, 모두 로그인 필요)
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
|
||||
// 컴포넌트는 이것을 직접 부르지 않고 스토어(mail-settings.store · mail-block.store)를 거친다
|
||||
export function useMailSettings() {
|
||||
const api = useApi()
|
||||
|
||||
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기) */
|
||||
function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
|
||||
return api.get<MailSettings, MailSettings>('/mail-settings', options)
|
||||
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기 · 묶지 않음) */
|
||||
async function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
|
||||
return mailSettingsOf(await api.get<MailSettingsReply, MailSettingsReply>('/mail-settings', options))
|
||||
}
|
||||
|
||||
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체 */
|
||||
function updateSettings(patch: Partial<MailSettings>, options?: MailSettingsRequestOptions): Promise<MailSettings> {
|
||||
return api.patch<MailSettings, MailSettings>('/mail-settings', patch, options)
|
||||
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체. groupByConversation 은 불리언만 받는다(그 밖은 400 VAL_001) */
|
||||
async function updateSettings(
|
||||
patch: Partial<MailSettings>,
|
||||
options?: MailSettingsRequestOptions,
|
||||
): Promise<MailSettings> {
|
||||
return mailSettingsOf(await api.patch<MailSettingsReply, MailSettingsReply>('/mail-settings', patch, options))
|
||||
}
|
||||
|
||||
/** 차단한 주소 · 도메인 — 최근에 차단한 것부터 500개까지 */
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
|
||||
import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
|
||||
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
|
||||
import type { MailSettings } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.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'
|
||||
|
||||
// 메일 화면이 탭으로 돌아올 때 보내기 · 읽기 설정을 다시 받는다 (14단계 — 2026-11-06 실사용 시험: 다른 탭에서 끈 묶기가 열어 둔 탭에 남았다)
|
||||
// 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const SETTINGS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
|
||||
|
||||
describe('useMailSettingsRefresh (탭으로 돌아오면 설정 다시 받기)', () => {
|
||||
let scope: EffectScope
|
||||
let clock: ManualClock
|
||||
let visibility: DocumentVisibilityState
|
||||
let server: MailSettings
|
||||
let load: () => MockResult
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
scope = effectScope()
|
||||
clock = createManualClock()
|
||||
visibility = 'visible'
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
|
||||
server = SETTINGS
|
||||
load = () => ok(server)
|
||||
requests = installMockApi((request) => (request.method === 'GET' ? load() : ok(server)))
|
||||
// 메일 화면에 들어올 때 받아 둔다 (MailLayout 의 ensureLoaded)
|
||||
await useMailSettingsStore().load()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
Reflect.deleteProperty(document, 'visibilityState')
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function start() {
|
||||
scope.run(() => useMailSettingsRefresh({ clock }))
|
||||
}
|
||||
|
||||
async function comeBack(afterMs: number) {
|
||||
visibility = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
clock.advance(afterMs)
|
||||
visibility = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const fetched = () => requests.filter((request) => request.method === 'GET' && request.url === '/mail-settings').length
|
||||
|
||||
it('마지막으로 받은 지 60초가 지난 뒤 탭으로 돌아오면 조용히 다시 받아 그 값을 쓴다 — 묶기 · 보내기 취소 초 · 외부 이미지', async () => {
|
||||
start()
|
||||
server = { undoSendSeconds: 30, externalImages: 'always', groupByConversation: false }
|
||||
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
|
||||
expect(fetched()).toBe(2)
|
||||
expect(requests.at(-1)?.config.silent).toBe(true)
|
||||
const store = useMailSettingsStore()
|
||||
expect(store.groupByConversation).toBe(false)
|
||||
expect(store.knownUndoSendSeconds).toBe(30)
|
||||
expect(store.settings?.externalImages).toBe('always')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('60초가 안 됐으면 다시 받지 않는다 — 받은 뒤에는 또 60초를 센다', async () => {
|
||||
start()
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
|
||||
expect(fetched()).toBe(1)
|
||||
|
||||
await comeBack(1)
|
||||
expect(fetched()).toBe(2)
|
||||
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
|
||||
expect(fetched()).toBe(2)
|
||||
})
|
||||
|
||||
it('숨는 때에는 받지 않는다 — 다시 보일 때만', async () => {
|
||||
start()
|
||||
clock.advance(MAIL_SETTINGS_REFRESH_AFTER_MS * 2)
|
||||
visibility = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await flushPromises()
|
||||
|
||||
expect(fetched()).toBe(1)
|
||||
})
|
||||
|
||||
it('세션을 쓸 수 없으면(제한된 세션 · 로그아웃) 받지 않는다', async () => {
|
||||
start()
|
||||
// 같은 직원의 세션에 제한이 붙었다 — 받아 둔 설정은 그대로지만 묻지 않는다
|
||||
useAuthStore().markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(useMailSettingsStore().status).toBe('ready')
|
||||
expect(fetched()).toBe(1)
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(fetched()).toBe(1)
|
||||
})
|
||||
|
||||
it('받지 못하면 알리지 않고 지금 값을 둔다 — 60초 뒤 다시 보일 때 또 해 본다', async () => {
|
||||
start()
|
||||
load = () => fail(503, 'SYS_001')
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
|
||||
expect(fetched()).toBe(2)
|
||||
expect(useMailSettingsStore().settings).toEqual(SETTINGS)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
load = () => ok({ ...SETTINGS, groupByConversation: false })
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(fetched()).toBe(3)
|
||||
expect(useMailSettingsStore().groupByConversation).toBe(false)
|
||||
})
|
||||
|
||||
it('화면이 사라지면 그만 본다', async () => {
|
||||
start()
|
||||
scope.stop()
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(fetched()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { onScopeDispose, watch } from 'vue'
|
||||
|
||||
import { systemClock, type Clock } from './useClock'
|
||||
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
|
||||
// 보내기 · 읽기 설정을 메일 화면에서 다시 받는 때 (14단계 — 2026-11-06 실사용 시험: 다른 탭에서 끈 [대화로 묶기]가 열어 둔 탭에 남았다)
|
||||
// - 메일 화면(MailLayout)이 있는 동안, 탭이 다시 보이게 될 때 설정을 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다(알리지 않는다)
|
||||
// — 받은 묶기 값이 다르면 메일함 화면이 그 값으로 목록을 다시 열고(MailboxPage), 보내기 취소 초 · 외부 이미지도 받은 값을 쓴다
|
||||
// - 마지막으로 받은 때는 화면에 들어온 때(그때 받아 두었거나 지금 받는다)와 받을 때마다(스토어의 fetchSeq)다. 다시 받기를 시작하면 그때로 —
|
||||
// 받지 못하면(알리지 않는다) 60초 뒤 다시 보일 때 또 해 본다
|
||||
// - 세션이 끝났으면(로그아웃 · 세션 만료 · 제한된 세션) 받지 않는다. 저장하는 중 · 받는 사이 고른 값은 스토어가 지킨다(refresh)
|
||||
|
||||
export interface MailSettingsRefreshOptions {
|
||||
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
function isPageVisible(): boolean {
|
||||
return typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||
}
|
||||
|
||||
export function useMailSettingsRefresh(options: MailSettingsRefreshOptions = {}): void {
|
||||
const clock = options.clock ?? systemClock
|
||||
const authStore = useAuthStore()
|
||||
const settingsStore = useMailSettingsStore()
|
||||
|
||||
/** 설정을 마지막으로 받은 때 (ms) */
|
||||
let fetchedAt = clock.now()
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (!isPageVisible() || !authStore.canUseApp) return
|
||||
const now = clock.now()
|
||||
if (now - fetchedAt < MAIL_SETTINGS_REFRESH_AFTER_MS) return
|
||||
fetchedAt = now
|
||||
void settingsStore.refresh()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => settingsStore.fetchSeq,
|
||||
() => {
|
||||
fetchedAt = clock.now()
|
||||
},
|
||||
)
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
onScopeDispose(() => document.removeEventListener('visibilitychange', onVisibilityChange))
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { useMailStore } from '@/stores/mail.store'
|
||||
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
|
||||
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
|
||||
// - 뺀 뒤(leaveRemoved): 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// 대화로 묶은 메일함(14단계)은 같은 결로 그 줄의 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄의 마지막 메일 (스토어의 neighborAfterRemoval).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
|
||||
export interface MessageRemovalOutcome {
|
||||
@@ -55,7 +56,7 @@ export function useMessageRemoval() {
|
||||
}
|
||||
|
||||
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
|
||||
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
|
||||
const removed = done && !stale && !mailStore.isListedMessage(id)
|
||||
return { removed, nextId: removed ? nextId : null }
|
||||
}
|
||||
|
||||
|
||||
@@ -160,4 +160,30 @@ describe('usePendingSendRefresh — 보낼 시각이 지나면 다시 본다', (
|
||||
expect(listRequests()).toEqual([])
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('읽기 화면 "같은 대화" 칸에만 있는 붙잡힌 메일도 지켜본다 — 보낼 시각 + 2초에 그 대화를 알리지 않고 다시 받아 "보내는 중"이 내려간다 (14단계)', async () => {
|
||||
let thread = [summary('m3', null), summary('u1', { kind: 'undo', sendAt: at(10_000) })]
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/scheduled-messages') return page([])
|
||||
if (request.url.endsWith('/conversation')) {
|
||||
return ok({ items: thread, pageInfo: { hasNext: false, nextCursor: null, total: thread.length }, basis: 'index' })
|
||||
}
|
||||
return ok({})
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.loadConversation('m3')
|
||||
scope.run(() => usePendingSendRefresh({ clock }))
|
||||
const conversations = () => requests.filter((request) => request.url.endsWith('/conversation'))
|
||||
expect(conversations()).toHaveLength(1)
|
||||
|
||||
thread = [summary('m3', null), summary('u1', null)]
|
||||
await advance(11_999)
|
||||
expect(conversations()).toHaveLength(1)
|
||||
await advance(1)
|
||||
|
||||
expect(conversations()).toHaveLength(2)
|
||||
expect(conversations()[1]?.config.silent).toBe(true)
|
||||
expect(store.conversationItems.find((item) => item.id === 'u1')?.pendingSend).toBeNull()
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,8 +17,9 @@ import { useMailStore } from '@/stores/mail.store'
|
||||
// 엔진이 붙잡아 둔 발송을 풀 때(보냄) 화면을 다시 맞춘다 (계약 2절 "보내기 취소 · 예약 발송" — 9단계, 메일 화면 MailLayout 이 시작)
|
||||
// 엔진이 풀어도 새 메일 확인(GET /mail-state) · 실시간 알림의 state 는 바뀌지 않는다 — 그대로 두면 보낸편지함의 "보내는 중" · "예약 · 시각",
|
||||
// 패널의 「예약됨」 수, 읽기 화면의 청록 줄이 계속 남고, 그 메일의 [고치기] · [예약 취소]가 매번 "이미 보낸 메일"(MAIL_014)이 된다 (2026-10-29 리뷰).
|
||||
// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄)의 pendingSend.sendAt — 같은 메일은 하나로 본다
|
||||
// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수, 알리지 않는다)
|
||||
// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄) · 읽기 화면 "같은 대화" 칸의 줄(14단계)의
|
||||
// pendingSend.sendAt — 같은 메일은 하나로 본다
|
||||
// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수 · 붙잡힌 메일이 든 같은 대화, 알리지 않는다)
|
||||
// - 다시 본 뒤에도 시각이 지난 채 남아 있으면(서버가 늦음 · 이 PC 시계가 빠름) 5초마다 세 번까지 더 보고, 그래도 남으면 그 발송은 그만 본다
|
||||
// (다음 새 메일 확인 · 화면 이동이 맞춘다 — 같은 요청을 끝없이 보내지 않게). 처음부터 시각이 지나 보이는 것(시계가 어긋남)도 5초 뒤부터 같다
|
||||
// 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts)
|
||||
@@ -40,7 +41,13 @@ export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) {
|
||||
const givenUp = new Set<string>()
|
||||
|
||||
const watches = computed<PendingWatch[]>(() =>
|
||||
pendingWatchesOf([...mailStore.messages, mailStore.selected, mailStore.nextScheduled]),
|
||||
pendingWatchesOf([
|
||||
// 보이는 목록의 줄 — 대화로 묶은 메일함은 줄마다 마지막 메일 (14단계 — 줄 안의 다른 붙잡힌 메일은 시각을 몰라 다음 맞추기를 기다린다)
|
||||
...mailStore.listedSummaries,
|
||||
mailStore.selected,
|
||||
mailStore.nextScheduled,
|
||||
...mailStore.conversationItems,
|
||||
]),
|
||||
)
|
||||
|
||||
/** 지금 지켜볼 것 — 그만 보기로 한 것은 빼고, 사라진 것은 잊는다 */
|
||||
|
||||
@@ -74,7 +74,7 @@ export function useScheduledMessageActions() {
|
||||
|
||||
/** 목록에서 빠졌는가 — 빠졌으면 삭제처럼 다음 메일(분할 보기) · 목록(목록 보기)으로 */
|
||||
function removedFromList(messageId: string): boolean {
|
||||
return !mailStore.messages.some((message) => message.id === messageId)
|
||||
return !mailStore.isListedMessage(messageId)
|
||||
}
|
||||
|
||||
/** [예약 취소] — 보내지 않고 임시보관함으로 */
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import type { Clock } from '@/composables/useClock'
|
||||
import type { SearchPeriod } from '@/shared/constants/mail-search'
|
||||
import {
|
||||
isReversedPeriod,
|
||||
isSearchDay,
|
||||
normalizeSearchConditions,
|
||||
periodOf,
|
||||
periodRange,
|
||||
type SearchConditions,
|
||||
} from '@/shared/utils/mail-search'
|
||||
|
||||
/** 기간 칸의 잘못 — 시작이 끝보다 늦음(order) · 고를 수 없는 날짜(range — 1970-01-01 ~ 9999-12-30 밖) */
|
||||
export type SearchPeriodProblem = 'order' | 'range'
|
||||
|
||||
/** 「조건으로 찾기」 패널의 칸 값 — 날짜는 <input type="date"> 값(YYYY-MM-DD, 비면 '') */
|
||||
export interface SearchConditionsDraft {
|
||||
from: string
|
||||
to: string
|
||||
subject: string
|
||||
body: string
|
||||
period: SearchPeriod
|
||||
since: string
|
||||
until: string
|
||||
hasAttachment: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 「조건으로 찾기」 패널의 칸 값 (14단계 — 디자인 원본 Search14). 걸린 조건으로 채우고(fill), 넘길 조건을 만든다(take), 모두 비운다(clear — [조건 지우기]).
|
||||
* - 기간은 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기로 채운다. 미리 정한 기간은 넘길 때 그 날짜(그만큼 앞의 같은 날 ~ 오늘)로 바꾼다
|
||||
* - 미리 정한 기간에서 날짜 고르기로 바꾸면 빈 날짜 칸을 그 기간의 날짜로 채워 둔다 — 조금만 고치면 되게
|
||||
* - 날짜 고르기의 시작이 끝보다 늦거나 고를 수 없는 날짜면 넘기지 않고 periodProblem 을 둔다 (칸 아래에 알린다)
|
||||
* 오늘은 주입한 시계로 센다 (테스트는 손으로 돌리는 시계)
|
||||
*/
|
||||
export function useSearchConditionsDraft(clock: Clock) {
|
||||
const draft = reactive<SearchConditionsDraft>({
|
||||
from: '',
|
||||
to: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
period: 'all',
|
||||
since: '',
|
||||
until: '',
|
||||
hasAttachment: false,
|
||||
})
|
||||
const periodProblem = ref<SearchPeriodProblem | null>(null)
|
||||
/** 바로 전에 고른 기간 — 날짜 고르기로 바꿀 때 그 날짜로 칸을 채운다 */
|
||||
let previousPeriod: SearchPeriod = 'all'
|
||||
|
||||
function today(): Date {
|
||||
return new Date(clock.now())
|
||||
}
|
||||
|
||||
/** 걸린 조건으로 칸을 채운다 (열 때) */
|
||||
function fill(conditions: SearchConditions | null) {
|
||||
const value = normalizeSearchConditions(conditions)
|
||||
draft.from = value.from
|
||||
draft.to = value.to
|
||||
draft.subject = value.subject
|
||||
draft.body = value.body
|
||||
draft.period = periodOf(value.since, value.until, today())
|
||||
draft.since = value.since ?? ''
|
||||
draft.until = value.until ?? ''
|
||||
draft.hasAttachment = value.hasAttachment
|
||||
previousPeriod = draft.period
|
||||
periodProblem.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 칸을 모두 비운다 — 패널의 [조건 지우기] (2026-11-07): 글 칸 · 기간(전체 기간) · 날짜 · 첨부 체크 · 칸 아래 알림까지.
|
||||
* 찾지는 않는다 — 걸린 조건은 [찾기]를 누를 때까지 그대로다
|
||||
*/
|
||||
function clear() {
|
||||
fill(null)
|
||||
}
|
||||
|
||||
/** 기간 고르기를 바꾼다 */
|
||||
function changePeriod(period: SearchPeriod) {
|
||||
draft.period = period
|
||||
periodProblem.value = null
|
||||
if (period === 'custom' && !draft.since && !draft.until) {
|
||||
const range = periodRange(previousPeriod, today())
|
||||
draft.since = range.since ?? ''
|
||||
draft.until = range.until ?? ''
|
||||
}
|
||||
previousPeriod = period
|
||||
}
|
||||
|
||||
/** 날짜 칸을 바꾼다 — 알리던 잘못을 지운다 */
|
||||
function changeDay(edge: 'since' | 'until', value: string) {
|
||||
draft[edge] = value
|
||||
periodProblem.value = null
|
||||
}
|
||||
|
||||
/** 넘길 조건 — 기간이 잘못됐으면 null (periodProblem 에 까닭) */
|
||||
function take(): SearchConditions | null {
|
||||
let since: string | null
|
||||
let until: string | null
|
||||
if (draft.period === 'custom') {
|
||||
since = draft.since || null
|
||||
until = draft.until || null
|
||||
if ((since !== null && !isSearchDay(since)) || (until !== null && !isSearchDay(until))) {
|
||||
periodProblem.value = 'range'
|
||||
return null
|
||||
}
|
||||
if (isReversedPeriod(since, until)) {
|
||||
periodProblem.value = 'order'
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
;({ since, until } = periodRange(draft.period, today()))
|
||||
}
|
||||
periodProblem.value = null
|
||||
return normalizeSearchConditions({
|
||||
from: draft.from,
|
||||
to: draft.to,
|
||||
subject: draft.subject,
|
||||
body: draft.body,
|
||||
since,
|
||||
until,
|
||||
hasAttachment: draft.hasAttachment,
|
||||
})
|
||||
}
|
||||
|
||||
return { draft, periodProblem, fill, clear, changePeriod, changeDay, take }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { nextTick, onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
/** 닫을 때 초점을 [조건]으로 돌릴지 — 기본은 돌린다 */
|
||||
export interface SearchConditionsCloseOptions {
|
||||
restoreFocus?: boolean
|
||||
}
|
||||
|
||||
interface Options {
|
||||
/** 검색 칸(form) — 이 안을 누르거나 이 안으로 옮기는 초점은 바깥이 아니다 */
|
||||
container: Ref<HTMLElement | null>
|
||||
/** [조건] — 닫을 때 초점을 돌려줄 곳 */
|
||||
toggle: Ref<{ focus: () => void } | null>
|
||||
/** 연 뒤(패널이 그려진 뒤) — 첫 칸으로 초점 */
|
||||
onOpened: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 칸의 「조건으로 찾기」 패널 열고 닫기 (14단계 — 디자인 원본 Search14).
|
||||
* 열린 동안 바깥을 누르거나(pointerdown — 초점은 누른 곳으로 간다) 키보드로 검색 칸 밖으로 나가거나(focusout)
|
||||
* 다른 화면으로 가면(뒤로 가기 등) 닫는다 — 이때는 초점을 옮기지 않는다. 그 밖에 닫을 때는 [조건]으로 초점을 돌린다(restoreFocus 로 끈다).
|
||||
* 패널의 칸은 열 때마다 걸린 조건으로 다시 채운다(패널이 v-if 로 새로 그려진다)
|
||||
*/
|
||||
export function useSearchConditionsPopup(options: Options) {
|
||||
const route = useRoute()
|
||||
const isOpen = ref<boolean>(false)
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
const target = event.target instanceof Node ? event.target : null
|
||||
if (target && options.container.value?.contains(target)) return
|
||||
close({ restoreFocus: false })
|
||||
}
|
||||
|
||||
function listenOutside(on: boolean) {
|
||||
if (on) document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
else document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
}
|
||||
|
||||
/** 키보드로 패널 · 검색 칸 밖으로 나가면 닫는다 (마우스로 누른 곳은 pointerdown 이 본다 — relatedTarget 이 없다) */
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget instanceof Node ? event.relatedTarget : null
|
||||
if (!isOpen.value || !next || options.container.value?.contains(next)) return
|
||||
close({ restoreFocus: false })
|
||||
}
|
||||
|
||||
async function open() {
|
||||
if (isOpen.value) return
|
||||
isOpen.value = true
|
||||
listenOutside(true)
|
||||
await nextTick()
|
||||
if (isOpen.value) options.onOpened()
|
||||
}
|
||||
|
||||
function close(closeOptions: SearchConditionsCloseOptions = {}) {
|
||||
if (!isOpen.value) return
|
||||
isOpen.value = false
|
||||
listenOutside(false)
|
||||
if (closeOptions.restoreFocus !== false) void nextTick(() => options.toggle.value?.focus())
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isOpen.value) close()
|
||||
else void open()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => close({ restoreFocus: false }),
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => listenOutside(false))
|
||||
|
||||
return { isOpen, open, close, toggle, onFocusOut }
|
||||
}
|
||||
Reference in New Issue
Block a user