feat(frontend): 읽기 화면에 같은 대화 칸을 더한다 (14단계 화면 2차)

- 본문 · 첨부 아래 "같은 대화 N통" — 오래된 차례의 줄(48), 지금 보는 메일 줄은 누를 수 없고,
  다른 줄은 그 자리에서 펼쳐 본문(MailBodyFrame — 강조 쿼리 없이)과 [답장] · [이 메일만 열기]
- 100통씩 받고 쪽이 모자라면 5번까지 이어 묻기 + [더 보기], 통 수를 모르면 수 없이
- 실패는 칸 안 안내 + [다시 시도](알림 없음), 이 경로가 없는 옛 서버(404)면 칸이 없다
- 다시 받기는 메일함 통 수가 바뀔 때 · 쓰기 화면에서 돌아올 때 · 붙잡힌 메일이 풀릴 때 —
  읽음 표시만 바뀐 소식으로는 받지 않고, 끝난 때부터 10초 간격
- 펼친 안 읽은 메일은 읽음으로(읽기 화면과 같은 길), 펼친 본문 안 Ctrl+P 는 그 메일 인쇄

리뷰 반영: 누른 단추가 사라질 때 초점 옮기기, 읽음 소식으로 요청이 두 배가 되던 것,
받는 중 온 소식 · 시계 뒤로 · [더 보기] 뒤 붙잡힌 메일, 받아 둔 본문 10통까지,
본문 안내 줄 · 지워진 메일, 목록 보기에서는 [이 메일만 열기]가 기록을 바꿔 끼운다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 10:15:44 +09:00
parent 37d689e113
commit 8f02814a4f
26 changed files with 3910 additions and 17 deletions
@@ -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()
})
}
+22
View File
@@ -10,8 +10,10 @@ 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,
ConversationPage,
Draft,
DraftPayload,
DraftSaved,
@@ -49,6 +51,7 @@ import type {
UpdateMessagePayload,
} from '@/shared/types/mail'
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
import { conversationPageOf } from '@/shared/utils/mail-conversation'
import { pendingSendOf } from '@/shared/utils/pending-send'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
@@ -273,6 +276,24 @@ export function useMail() {
})
}
/**
* 그 메일의 같은 대화 — 오래된 차례, 한 통뿐이면 그 메일 하나 (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)
}
function updateMessage(
id: string,
changes: UpdateMessagePayload,
@@ -478,6 +499,7 @@ export function useMail() {
listMessages,
getSearchIndex,
getMessage,
getConversation,
updateMessage,
updateMessages,
deleteMessages,
@@ -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,12 @@ export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) {
const givenUp = new Set<string>()
const watches = computed<PendingWatch[]>(() =>
pendingWatchesOf([...mailStore.messages, mailStore.selected, mailStore.nextScheduled]),
pendingWatchesOf([
...mailStore.messages,
mailStore.selected,
mailStore.nextScheduled,
...mailStore.conversationItems,
]),
)
/** 지금 지켜볼 것 — 그만 보기로 한 것은 빼고, 사라진 것은 잊는다 */