generated from ttipo/project-base
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:
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
]),
|
||||
)
|
||||
|
||||
/** 지금 지켜볼 것 — 그만 보기로 한 것은 빼고, 사라진 것은 잊는다 */
|
||||
|
||||
Reference in New Issue
Block a user