generated from ttipo/project-base
feat(frontend): 실시간 새 메일 · 바탕화면 알림 · 설정 › 알림
- 로그인한 동안 앱 전체에서 실시간 연결 — 브라우저마다 하나(Web Locks 잠금 + BroadcastChannel 로 다른 탭에 나눔), fetch 로 읽기, 401 은 세션 갱신 한 번, 자리 잡은 뒤에만 다시 잇기 간격을 처음부터(열리자마자 끝나는 연결에서 갱신 폭주 막기), 탭을 닫으면 이어받기 - 메일 화면: 연결이 살아 있는 동안 1분 확인을 멈추고, 바뀌면 1초 모아 맞추기 · 숨은 탭은 탭 제목 (N) 만 - 바탕화면 알림: 웹메일을 보고 있지 않을 때만(보이고 초점이 있는 탭 없음), 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로, 돌아오면 닫기 - 설정 › 메일 › 알림(허락 상태 · 제목 숨김 · 시험 알림 · 연결 상태)과 목록 위 한 번 권하기 줄, 알림 아이콘 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,19 @@ import { RouterView } from 'vue-router'
|
||||
|
||||
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
|
||||
import ToastHost from '@/components/feedback/ToastHost.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useLogoutSync } from '@/composables/useLogoutBroadcast'
|
||||
import { useMailRealtime } from '@/composables/useMailRealtime'
|
||||
import { useNewMailNotifier } from '@/composables/useNewMailNotifier'
|
||||
|
||||
// 다른 탭에서 로그아웃하면 이 탭도 로그인 화면으로 간다 (계약 1절 "클라이언트 처리 규칙")
|
||||
useLogoutSync()
|
||||
|
||||
// 실시간 새 메일 — 로그인한 동안 앱 전체에서 돈다(설정 등 다른 화면에서도 바탕화면 알림 · 연결 상태가 이어지게).
|
||||
// 메일 화면은 이 소식을 받아 목록 · 개수를 맞춘다 (계약 2절 "실시간 알림" — MailLayout 의 useMailStatePolling).
|
||||
// 사용자가 웹메일 탭(어느 탭이든)으로 돌아오면 띄운 바탕화면 알림을 닫는다
|
||||
const newMailNotifier = useNewMailNotifier()
|
||||
useMailRealtime({ clock: useClock(), onNewMail: newMailNotifier.notify, onUserReturn: newMailNotifier.dismiss })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료" · MailActions4 "오래 걸리는 처리")
|
||||
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
|
||||
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + (이동 링크 — 예: 바탕화면 알림을 막아 둠 [설정 열기]) + 닫기
|
||||
// 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
|
||||
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
|
||||
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -216,6 +217,20 @@ function runAction(toast: Toast) {
|
||||
outline-color: var(--color-on-brand-link);
|
||||
}
|
||||
|
||||
/* 흰 오류 카드 위의 단추 · 링크 (예: 바탕화면 알림을 막아 둠 — [설정 열기]) — 브랜드 바탕용 밝은 색은 흰 바탕에서 보이지 않는다 */
|
||||
.toast--error .toast__action {
|
||||
align-self: center;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.toast--error .toast__action:hover {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.toast--error .toast__action:focus-visible {
|
||||
outline-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.toast__close {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { FakeNotification } from '@/test-utils/notification'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
|
||||
import MessageList from './MessageList.vue'
|
||||
|
||||
// 목록 틀 — 새 메일 알림 줄 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리")
|
||||
// 목록 틀 — 새 메일 알림 줄 · 바탕화면 알림 권하기 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리" · "5단계")
|
||||
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
@@ -127,6 +130,44 @@ describe('MailListFrame (목록 칸의 틀)', () => {
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
})
|
||||
|
||||
describe('바탕화면 알림 권하기 (디자인 README "5단계 › 목록 위에서 한 번 권하기")', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
installFakeLocks()
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
vi.stubGlobal('Notification', FakeNotification)
|
||||
FakeNotification.reset('default')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
uninstallFakeLocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('메일함 목록 머리 아래에 둔다 — 검색 결과에는 두지 않는다', async () => {
|
||||
const { wrapper, store } = await mountList([summary('m1')])
|
||||
|
||||
const top = wrapper.get('.mail-list-frame__top')
|
||||
expect(top.find('.list-header + .notify-offer').exists()).toBe(true)
|
||||
|
||||
store.listSource = { kind: 'search', q: '회의', scope: 'all' }
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.notify-offer').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('[✕] 로 닫으면 누른 단추와 함께 사라진 초점을 목록 칸으로 옮긴다', async () => {
|
||||
const { wrapper, section } = await mountList([summary('m1')])
|
||||
const close = wrapper.get('.notify-offer__close')
|
||||
;(close.element as HTMLElement).focus()
|
||||
|
||||
await close.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.notify-offer').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(section)
|
||||
})
|
||||
})
|
||||
|
||||
it('Esc 는 고른 것을 푼다 — 도구 줄에 있던 초점은 목록 칸으로 옮긴다', async () => {
|
||||
const { wrapper, store, section } = await mountList([summary('m1'), summary('m2')])
|
||||
store.toggleCheck('m1')
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
|
||||
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
|
||||
// 그 아래 "새 메일 N통 · 맨 위로"(useListArrivalNotice)
|
||||
// 그 아래 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
|
||||
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음
|
||||
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
|
||||
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailNewNotice from '@/components/mail/MailNewNotice.vue'
|
||||
import MailNotifyOffer from '@/components/mail/MailNotifyOffer.vue'
|
||||
import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue'
|
||||
import MailSearchHeader from '@/components/mail/MailSearchHeader.vue'
|
||||
import MailSearchScope from '@/components/mail/MailSearchScope.vue'
|
||||
@@ -70,6 +71,14 @@ function focusSection() {
|
||||
sectionRef.value?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
/** 알림 권하기 줄이 사라졌다 — 누른 단추와 함께 초점이 사라졌으면 목록 칸으로 */
|
||||
function onOfferLeave() {
|
||||
void nextTick(() => {
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body) focusSection()
|
||||
})
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !selecting.value || event.defaultPrevented || isImeComposing(event)) return
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
@@ -119,6 +128,11 @@ watch(
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailSearchScope v-if="search" />
|
||||
<MailNotifyOffer
|
||||
v-else
|
||||
:variant="variant"
|
||||
@leave="onOfferLeave"
|
||||
/>
|
||||
<div
|
||||
class="mail-list-frame__notice"
|
||||
role="status"
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { mailNotifyStorageKey, useMailNotifyStore } from '@/stores/mail-notify.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import { FakeNotification } from '@/test-utils/notification'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
|
||||
import MailNotifyOffer from './MailNotifyOffer.vue'
|
||||
|
||||
// 목록 위에서 한 번 권하기 (디자인 원본 MailRealtime · README "5단계 › 목록 위에서 한 번 권하기")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
describe('MailNotifyOffer (바탕화면 알림 권하기 줄)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
window.localStorage.clear()
|
||||
installFakeLocks()
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
vi.stubGlobal('Notification', FakeNotification)
|
||||
FakeNotification.reset('default')
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
uninstallFakeLocks()
|
||||
vi.unstubAllGlobals()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountOffer(variant: 'split' | 'list' = 'list') {
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailNotifyOffer, {
|
||||
props: { variant },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('알림을 켜지도 닫지도 않았고 브라우저가 아직 묻지 않았으면 보인다 — 종 아이콘 · 글 · [알림 켜기] · [✕ 닫기]', async () => {
|
||||
const wrapper = await mountOffer()
|
||||
const bar = wrapper.get('[role="region"]')
|
||||
|
||||
expect(bar.attributes('aria-label')).toBe('바탕화면 알림 권하기')
|
||||
expect(bar.text()).toContain('바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.')
|
||||
expect(bar.get('.notify-offer__action').text()).toBe('알림 켜기')
|
||||
expect(bar.get('.notify-offer__close').attributes('aria-label')).toBe('닫기')
|
||||
})
|
||||
|
||||
it('분할 보기의 좁은 목록 칸에서는 짧은 글 "바탕화면 알림으로 새 메일 받기" — 긴 글은 title 로', async () => {
|
||||
const split = await mountOffer('split')
|
||||
const text = split.get('.notify-offer__text')
|
||||
|
||||
expect(text.text()).toBe('바탕화면 알림으로 새 메일 받기')
|
||||
expect(text.attributes('title')).toBe('바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.')
|
||||
split.unmount()
|
||||
|
||||
const list = await mountOffer('list')
|
||||
expect(list.get('.notify-offer__text').text()).toBe(
|
||||
'바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('허락을 묻는 동안 단추를 끄지 않고 aria-disabled — 초점이 남고, 다시 눌러도 다시 묻지 않는다', async () => {
|
||||
const wrapper = await mountOffer()
|
||||
const answer = FakeNotification.holdAnswer()
|
||||
const action = wrapper.get('.notify-offer__action')
|
||||
;(action.element as HTMLElement).focus()
|
||||
|
||||
await action.trigger('click')
|
||||
await flushPromises()
|
||||
expect(action.attributes('disabled')).toBeUndefined()
|
||||
expect(action.attributes('aria-disabled')).toBe('true')
|
||||
expect(document.activeElement).toBe(action.element)
|
||||
|
||||
await action.trigger('click')
|
||||
await flushPromises()
|
||||
expect(FakeNotification.requestCount).toBe(1)
|
||||
|
||||
answer('default')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.notify-offer__action').attributes('aria-disabled')).toBeUndefined()
|
||||
expect(document.activeElement).toBe(action.element)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['브라우저가 이미 막았으면', () => FakeNotification.reset('denied')],
|
||||
['브라우저가 이미 허락했으면', () => FakeNotification.reset('granted')],
|
||||
['지원하지 않으면', () => vi.stubGlobal('Notification', undefined)],
|
||||
[
|
||||
'닫은 적이 있으면',
|
||||
() =>
|
||||
window.localStorage.setItem(mailNotifyStorageKey(USER.email), JSON.stringify({ offerDismissed: true })),
|
||||
],
|
||||
['이미 켰으면', () => window.localStorage.setItem(mailNotifyStorageKey(USER.email), JSON.stringify({ enabled: true }))],
|
||||
])('%s 보이지 않는다', async (_label, arrange) => {
|
||||
// 설정 스토어는 줄을 그릴 때 만들어진다 — 그 전에 저장값 · 허락을 정한다
|
||||
arrange()
|
||||
|
||||
const wrapper = await mountOffer()
|
||||
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('[알림 켜기] → 허용하면 켜고 "바탕화면 알림을 켰습니다." — 줄은 사라진다', async () => {
|
||||
const wrapper = await mountOffer()
|
||||
FakeNotification.nextAnswer = 'granted'
|
||||
|
||||
await wrapper.get('.notify-offer__action').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(true)
|
||||
expect(useToastStore().toasts).toMatchObject([{ kind: 'success', message: '바탕화면 알림을 켰습니다.' }])
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(false)
|
||||
expect(wrapper.emitted('leave')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('[알림 켜기] → 막으면 오류 알림 + [설정 열기](설정 › 알림) — 줄은 사라진다', async () => {
|
||||
const wrapper = await mountOffer()
|
||||
FakeNotification.nextAnswer = 'denied'
|
||||
|
||||
await wrapper.get('.notify-offer__action').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(false)
|
||||
expect(useToastStore().toasts).toMatchObject([
|
||||
{
|
||||
kind: 'error',
|
||||
message: '브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.',
|
||||
action: { label: '설정 열기', to: { name: 'settings-mail-notify' } },
|
||||
},
|
||||
])
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('[알림 켜기] → 그냥 닫으면 줄은 그대로 · 알림도 없다', async () => {
|
||||
const wrapper = await mountOffer()
|
||||
FakeNotification.nextAnswer = 'default'
|
||||
|
||||
await wrapper.get('.notify-offer__action').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(wrapper.emitted('leave')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('[✕] → 다시 보이지 않는다 (브라우저 · 사용자마다 저장)', async () => {
|
||||
const wrapper = await mountOffer()
|
||||
|
||||
await wrapper.get('.notify-offer__close').trigger('click')
|
||||
|
||||
expect(wrapper.find('[role="region"]').exists()).toBe(false)
|
||||
expect(JSON.parse(window.localStorage.getItem(mailNotifyStorageKey(USER.email)) ?? '{}')).toMatchObject({
|
||||
offerDismissed: true,
|
||||
})
|
||||
expect(FakeNotification.requestCount).toBe(0)
|
||||
expect(wrapper.emitted('leave')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 위에서 한 번 권하기 — 바탕화면 알림을 켜지도 닫지도 않은 사람에게 (디자인 원본 MailRealtime · README "5단계 › 목록 위에서 한 번 권하기")
|
||||
// 목록 머리 아래 40 줄(바탕 #F3F8F8 — 새 메일 알림 줄과 같은 높이, 밀도 예외 — 줄 안의 단추는 36):
|
||||
// 종 아이콘 + 글 + [알림 켜기](강조 글자 굵게) + [✕ 닫기]. 분할 · 목록 보기 모두 (목록 틀 MailListFrame 이 메일함 목록에 둔다).
|
||||
// 분할 보기의 좁은 목록 칸(384)에서는 짧은 글 "바탕화면 알림으로 새 메일 받기"(긴 글은 title) — 단추와 함께 한 줄에 든다 (2026-10-28 화면 점검)
|
||||
// - 보이는 조건: 지원하고 · 브라우저가 아직 묻지 않았고(default) · 켜지 않았고 · 닫은 적이 없을 때 (mail-notify.store 의 offerVisible)
|
||||
// - [알림 켜기] → 브라우저가 묻는다 → 허용: 켜고 "바탕화면 알림을 켰습니다." · 막힘: 오류 알림 + [설정 열기] · 그냥 닫음: 줄은 그대로
|
||||
// 묻는 동안은 단추를 끄지 않고 aria-disabled 로만 막는다 — 초점이 그대로 남게 (다시 누르는 것은 스토어가 막는다)
|
||||
// - [✕] → 다시 보이지 않는다 (브라우저 · 사용자마다)
|
||||
// 줄이 사라지면 leave 를 알린다 — 누른 단추가 없어져 초점을 잃지 않게 목록 틀이 목록 칸에 초점을 준다
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import { useMailNotifyStore } from '@/stores/mail-notify.store'
|
||||
|
||||
interface Props {
|
||||
/** 분할 보기의 목록 칸(좁다) · 목록 보기의 표 (넓다) */
|
||||
variant?: 'split' | 'list'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'leave'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'list',
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const notifyStore = useMailNotifyStore()
|
||||
const toast = useToast()
|
||||
|
||||
const text = computed(() => (props.variant === 'split' ? MAIL_NOTIFY_TEXT.offerShortText : MAIL_NOTIFY_TEXT.offerText))
|
||||
|
||||
async function turnOn() {
|
||||
if (notifyStore.requesting) return
|
||||
const result = await notifyStore.turnOn()
|
||||
if (result === 'granted') {
|
||||
toast.success(MAIL_NOTIFY_TEXT.enabledToast)
|
||||
emit('leave')
|
||||
} else if (result === 'denied') {
|
||||
toast.error(MAIL_NOTIFY_TEXT.blockedToast, {
|
||||
label: MAIL_NOTIFY_TEXT.openSettings,
|
||||
to: { name: SETTINGS_ROUTES.notify },
|
||||
})
|
||||
emit('leave')
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
notifyStore.dismissOffer()
|
||||
emit('leave')
|
||||
}
|
||||
|
||||
// 사이트 설정에서 허락을 바꿨을 수 있다 — 목록을 열 때 다시 읽는다
|
||||
onMounted(() => notifyStore.refreshPermission())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="notifyStore.offerVisible"
|
||||
:class="['notify-offer', `notify-offer--${variant}`]"
|
||||
role="region"
|
||||
:aria-label="MAIL_NOTIFY_TEXT.offerRegion"
|
||||
>
|
||||
<AppIcon
|
||||
name="bell"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
class="notify-offer__icon"
|
||||
/>
|
||||
<span
|
||||
class="notify-offer__text"
|
||||
:title="MAIL_NOTIFY_TEXT.offerText"
|
||||
>{{ text }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="notify-offer__action"
|
||||
:aria-disabled="notifyStore.requesting ? 'true' : undefined"
|
||||
@click="turnOn"
|
||||
>
|
||||
{{ MAIL_NOTIFY_TEXT.offerAction }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="notify-offer__close"
|
||||
:aria-label="MAIL_NOTIFY_TEXT.offerClose"
|
||||
:title="MAIL_NOTIFY_TEXT.offerClose"
|
||||
@click="dismiss"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notify-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-notice-bar);
|
||||
padding: 0 var(--space-4) 0 var(--space-14);
|
||||
border-bottom: var(--border-width) solid var(--color-brand-tint-border);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand-surface);
|
||||
}
|
||||
|
||||
.notify-offer__icon {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 좁으면 글이 먼저 줄어든다 — 단추는 늘 보인다 */
|
||||
.notify-offer__text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notify-offer__action,
|
||||
.notify-offer__close {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--size-notice-action);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.notify-offer__action {
|
||||
padding: 0 var(--space-10);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notify-offer__action:hover:not([aria-disabled='true']),
|
||||
.notify-offer__close:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 허락을 묻는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
|
||||
.notify-offer__action[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.notify-offer__close {
|
||||
width: var(--size-notice-action);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.notify-offer__close:hover {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.notify-offer__action:focus-visible,
|
||||
.notify-offer__close:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
// 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때")
|
||||
// - [x] 새 메일이 오면 바탕화면 알림 보내기(44) — 켜면 브라우저가 허락을 묻는다(누른 뒤에만). 칸 아래 안내(12.5px):
|
||||
// 묻기 전(회색) · 허용됨(초록 + 체크) · 막힘(칸을 끄고 빨강 + 경고) · 지원하지 않음(칸을 끄고 회색) — notifyEnableState
|
||||
// 허락하지 않았으면(막음 · 그냥 닫음) 칸은 비운 채 돌아간다. 묻는 동안은 칸을 끄지 않고 aria-disabled 로만 막는다(초점이 그대로 남게).
|
||||
// 막혀서(또는 지원하지 않아서) 칸이 꺼지면 초점을 안내 글로 옮긴다 — 왜 꺼졌는지 바로 읽게
|
||||
// - 알림에 보일 것: [x] 메일 제목도 보이기 — 알림을 끄면 흐리게(누를 수 없다)
|
||||
// - [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 지금 탭에서 바로 "ARA 웹메일" / "알림이 잘 옵니다. …" (알림을 끄면 누를 수 없다).
|
||||
// 못 띄우면 조용히 넘어가지 않는다 — 만들 수 없는 브라우저(휴대폰 Chrome 등)면 "지원하지 않음", 허락이 사라졌으면 그 안내로 바꾸고 초점을 옮긴다
|
||||
// 고정 id — #notify-enabled · #notify-enabled-note · #notify-show-subject · #notify-test
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { showDesktopNotification } from '@/composables/useDesktopNotification'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
|
||||
import { notifyEnableState, type NotifyEnableNote } from '@/shared/utils/desktop-notify'
|
||||
import { useMailNotifyStore } from '@/stores/mail-notify.store'
|
||||
|
||||
interface EnableNote {
|
||||
text: string
|
||||
tone: 'muted' | 'success' | 'danger'
|
||||
icon: IconName | null
|
||||
}
|
||||
|
||||
const ENABLE_NOTES: Readonly<Record<NotifyEnableNote, EnableNote>> = {
|
||||
ask: { text: MAIL_NOTIFY_TEXT.noteAsk, tone: 'muted', icon: null },
|
||||
granted: { text: MAIL_NOTIFY_TEXT.noteGranted, tone: 'success', icon: 'check' },
|
||||
blocked: { text: MAIL_NOTIFY_TEXT.noteBlocked, tone: 'danger', icon: 'alert-circle' },
|
||||
unsupported: { text: MAIL_NOTIFY_TEXT.noteUnsupported, tone: 'muted', icon: null },
|
||||
}
|
||||
|
||||
const notifyStore = useMailNotifyStore()
|
||||
|
||||
const noteRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const enableState = computed(() => notifyEnableState(notifyStore.permission, notifyStore.settings))
|
||||
const note = computed(() => ENABLE_NOTES[enableState.value.note])
|
||||
/** 알림을 끈 동안은 보일 것 · 시험 알림을 흐리게 */
|
||||
const optionsDisabled = computed(() => !enableState.value.checked)
|
||||
|
||||
/** 칸 · 단추가 꺼져 초점을 잃을 때 — 까닭을 적은 안내 글로 옮긴다 */
|
||||
async function focusNote() {
|
||||
await nextTick()
|
||||
noteRef.value?.focus()
|
||||
}
|
||||
|
||||
async function onToggle(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (notifyStore.requesting) {
|
||||
// 허락을 묻는 중 — 방금 누른 것만 되돌린다 (칸은 aria-disabled · 켜려던 모양 그대로 답을 기다린다)
|
||||
input.checked = !input.checked
|
||||
return
|
||||
}
|
||||
if (!input.checked) {
|
||||
notifyStore.turnOff()
|
||||
return
|
||||
}
|
||||
const result = await notifyStore.turnOn()
|
||||
// 허락하지 않았으면 칸을 되돌린다 — 그려야 할 값(비움)이 그대로라 화면이 다시 그리지 않는다
|
||||
input.checked = enableState.value.checked
|
||||
if (enableState.value.disabled) await focusNote()
|
||||
else if (result !== 'granted') input.focus()
|
||||
}
|
||||
|
||||
function onShowSubject(event: Event) {
|
||||
notifyStore.setShowSubject((event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
const result = showDesktopNotification({ title: MAIL_NOTIFY_TEXT.testTitle, body: MAIL_NOTIFY_TEXT.testBody })
|
||||
if (result.status === 'shown') return
|
||||
if (result.status === 'unsupported') notifyStore.markUnsupported()
|
||||
else notifyStore.refreshPermission()
|
||||
// 칸 · 이 단추가 꺼진다 — 바뀐 안내 글로
|
||||
await focusNote()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notify-form">
|
||||
<div class="notify-form__enable">
|
||||
<label :class="['notify-form__toggle', { 'is-disabled': enableState.disabled }]">
|
||||
<input
|
||||
id="notify-enabled"
|
||||
type="checkbox"
|
||||
class="notify-form__checkbox"
|
||||
:checked="enableState.checked"
|
||||
:disabled="enableState.disabled"
|
||||
:aria-disabled="notifyStore.requesting ? 'true' : undefined"
|
||||
aria-describedby="notify-enabled-note"
|
||||
@change="onToggle"
|
||||
>
|
||||
<span>{{ MAIL_NOTIFY_TEXT.enable }}</span>
|
||||
</label>
|
||||
<p
|
||||
id="notify-enabled-note"
|
||||
ref="noteRef"
|
||||
:class="['form-message', 'notify-form__note', `notify-form__note--${note.tone}`]"
|
||||
role="status"
|
||||
tabindex="-1"
|
||||
:data-note="enableState.note"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="note.icon"
|
||||
:name="note.icon"
|
||||
:size="15"
|
||||
:stroke-width="note.tone === 'success' ? 2.2 : 1.9"
|
||||
/>
|
||||
<span>{{ note.text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
class="notify-form__content"
|
||||
:disabled="optionsDisabled"
|
||||
>
|
||||
<legend class="notify-form__legend">
|
||||
{{ MAIL_NOTIFY_TEXT.contentLegend }}
|
||||
</legend>
|
||||
<label class="notify-form__option">
|
||||
<input
|
||||
id="notify-show-subject"
|
||||
type="checkbox"
|
||||
class="notify-form__checkbox"
|
||||
:checked="notifyStore.settings.showSubject"
|
||||
aria-describedby="notify-show-subject-hint"
|
||||
@change="onShowSubject"
|
||||
>
|
||||
<span>{{ MAIL_NOTIFY_TEXT.showSubject }}</span>
|
||||
</label>
|
||||
<p
|
||||
id="notify-show-subject-hint"
|
||||
class="form-message notify-form__note"
|
||||
>
|
||||
{{ MAIL_NOTIFY_TEXT.showSubjectHint }}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="notify-form__test">
|
||||
<button
|
||||
id="notify-test"
|
||||
type="button"
|
||||
class="notify-form__test-button"
|
||||
:disabled="optionsDisabled"
|
||||
@click="sendTest"
|
||||
>
|
||||
<AppIcon
|
||||
name="bell"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ MAIL_NOTIFY_TEXT.test }}</span>
|
||||
</button>
|
||||
<span class="notify-form__test-hint">{{ MAIL_NOTIFY_TEXT.testHint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notify-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
.notify-form__enable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.notify-form__toggle,
|
||||
.notify-form__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notify-form__toggle {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notify-form__toggle.is-disabled {
|
||||
color: var(--color-icon-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.notify-form__checkbox {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
}
|
||||
|
||||
/* 칸 아래 안내 — 고르기 상자 글자 선(28)에 맞춰 들여 쓴다 */
|
||||
.notify-form__note {
|
||||
margin-left: var(--space-28);
|
||||
}
|
||||
|
||||
/* 프로그램이 옮긴 초점(칸이 꺼졌을 때)에는 고리를 그리지 않는다 — 글을 읽게 할 뿐이다 */
|
||||
.notify-form__note:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.notify-form__checkbox[aria-disabled='true'] {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.notify-form__note--success {
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.notify-form__note--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.notify-form__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.notify-form__content:disabled {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.notify-form__content:disabled .notify-form__option {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.notify-form__content:disabled .notify-form__note {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.notify-form__legend {
|
||||
margin: 0 0 var(--space-4);
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.notify-form__test {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.notify-form__test-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notify-form__test-button:hover:not(:disabled) {
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.notify-form__test-button:disabled {
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-icon-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.notify-form__test-hint {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 알림의 "실시간 연결" (디자인 원본 SettingsNotify) — 이 탭이 보는 연결 상태 (stores/mail-realtime.store.ts)
|
||||
// ● 초록 "연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
|
||||
// 끊긴 동안은 상태 줄 아래에 "연결될 때까지 바탕화면 알림은 오지 않습니다." (2026-10-28 화면 점검 반영)
|
||||
// 메일 화면에서는 끊겨도 알리지 않는다 — 여기서만 볼 수 있다 (디자인 README "5단계 › 실시간 반영")
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
|
||||
import { realtimeLinkText } from '@/shared/utils/mail-realtime'
|
||||
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
|
||||
|
||||
const realtimeStore = useMailRealtimeStore()
|
||||
|
||||
const link = computed(() => realtimeLinkText(realtimeStore.status))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="realtime-link"
|
||||
aria-labelledby="notify-realtime-title"
|
||||
>
|
||||
<h2
|
||||
id="notify-realtime-title"
|
||||
class="realtime-link__title"
|
||||
>
|
||||
{{ MAIL_NOTIFY_TEXT.realtimeTitle }}
|
||||
</h2>
|
||||
<!-- 상태가 바뀌면 끊긴 동안의 알림까지 함께 읽힌다 -->
|
||||
<div
|
||||
class="realtime-link__live"
|
||||
role="status"
|
||||
>
|
||||
<p
|
||||
id="notify-realtime-status"
|
||||
class="realtime-link__status"
|
||||
:data-status="realtimeStore.status"
|
||||
>
|
||||
<span
|
||||
:class="['realtime-link__dot', { 'is-live': link.live }]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ link.text }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="link.notice"
|
||||
id="notify-realtime-notice"
|
||||
class="realtime-link__notice"
|
||||
>
|
||||
{{ link.notice }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="form-message">
|
||||
{{ link.hint }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.realtime-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.realtime-link__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.realtime-link__live {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.realtime-link__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.realtime-link__dot {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-status-dot);
|
||||
height: var(--size-status-dot);
|
||||
border-radius: var(--radius-round);
|
||||
background: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.realtime-link__dot.is-live {
|
||||
background: var(--color-status-live);
|
||||
}
|
||||
|
||||
/* 끊긴 동안 — 상태 줄의 글자 선(점 8 + 간격 8)에 맞춰 들여 쓴다 */
|
||||
.realtime-link__notice {
|
||||
margin: 0 0 0 calc(var(--size-status-dot) + var(--space-8));
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
|
||||
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
|
||||
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
|
||||
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
|
||||
interface Props {
|
||||
/** 제목(h1)의 id — main 이 aria-labelledby 로 가리킨다 */
|
||||
@@ -40,6 +41,8 @@ defineProps<Props>()
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-page__column {
|
||||
|
||||
@@ -217,8 +217,11 @@ function shouldRefresh(code: string, config: InternalAxiosRequestConfig): boolea
|
||||
)
|
||||
}
|
||||
|
||||
/** 갱신 실패가 "세션이 끝났다"는 뜻인지 — 네트워크·5xx 장애면 세션 만료로 단정하지 않는다 */
|
||||
function isSessionEnd(code: ErrorCode): boolean {
|
||||
/**
|
||||
* 갱신 실패가 "세션이 끝났다"는 뜻인지 — 네트워크·5xx 장애면 세션 만료로 단정하지 않는다.
|
||||
* axios 를 거치지 않는 연결(실시간 알림 — useMailRealtime)도 같은 규칙으로 가른다
|
||||
*/
|
||||
export function isSessionEnd(code: ErrorCode): boolean {
|
||||
return code === 'AUTH_001' || code === 'AUTH_002'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import notificationIconUrl from '@/assets/brand/notification-icon.png'
|
||||
import { DESKTOP_NOTIFY_TAG } from '@/shared/constants/mail-notify'
|
||||
import { supportsDesktopNotification } from '@/shared/utils/browser-support'
|
||||
import type { DesktopNotifyPermission } from '@/shared/utils/desktop-notify'
|
||||
|
||||
// 브라우저 바탕화면 알림(Notification) — 지원 · 허락 확인, 허락 묻기, 띄우기 (디자인 README "5단계 › 바탕화면 알림")
|
||||
// 모양은 운영체제 · 브라우저가 그린다 — 정하는 것은 제목 · 내용 · 아이콘(ARA 마크, 바탕 #0B3640 둥근 사각)뿐이다.
|
||||
// 아이콘은 assets/brand/notification-icon.png (192px — logo-ara-white.png 를 둥근 사각 가운데 34/44 폭으로 얹었다).
|
||||
// 알림은 하나의 tag 로 앞 알림을 바꿔 쓴다(renotify 로 다시 알린다) — 바탕화면에 쌓이지 않는다.
|
||||
// 허락은 사용자가 단추 · 칸을 누른 뒤에만 묻는다 (requestNotificationPermission 은 누른 처리 안에서 기다리기 전에 부른다).
|
||||
// 휴대폰 Chrome 처럼 Notification 은 있는데 페이지에서 만들 수 없는(만들면 예외를 던지는) 브라우저가 있다 — 그 결과를
|
||||
// 'unsupported' 로 돌려주고, 부르는 쪽이 설정(mail-notify.store 의 markUnsupported)을 "지원하지 않음"으로 바꾼다.
|
||||
|
||||
/** lib.dom 에 아직 없는 renotify — 같은 tag 로 바꿔 쓸 때도 다시 알린다 (지원하지 않는 브라우저는 무시한다) */
|
||||
type DesktopNotificationOptions = NotificationOptions & { renotify?: boolean }
|
||||
|
||||
export function currentNotificationPermission(): DesktopNotifyPermission {
|
||||
if (!supportsDesktopNotification()) return 'unsupported'
|
||||
try {
|
||||
return Notification.permission
|
||||
} catch {
|
||||
return 'unsupported'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 허락을 묻는다 — 이미 정했으면(허용 · 차단) 브라우저는 묻지 않고 그 값을 준다. 그냥 닫으면 'default' 다.
|
||||
* 누른 처리 안에서 기다리기 전에 부른다(브라우저가 사용자 동작 없이 묻는 것을 막는다). 실패하면 지금 값을 돌려준다.
|
||||
* 콜백 방식(옛 Safari)은 받지 않는다 — 그런 브라우저는 Web Locks 도 없어 지원하지 않는 것으로 본다 (browser-support.ts)
|
||||
*/
|
||||
export async function requestNotificationPermission(): Promise<DesktopNotifyPermission> {
|
||||
if (!supportsDesktopNotification()) return 'unsupported'
|
||||
try {
|
||||
return await Notification.requestPermission()
|
||||
} catch {
|
||||
return currentNotificationPermission()
|
||||
}
|
||||
}
|
||||
|
||||
export interface DesktopNotificationInput {
|
||||
title: string
|
||||
body: string
|
||||
/** 눌렀을 때 — 창을 앞으로 가져오고 알림을 닫은 뒤에 부른다 */
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 띄운 결과 — shown: 띄웠다(close 로 닫는다) · not-allowed: 허락이 없다(지원하지 않음 포함) ·
|
||||
* unsupported: 만들 수 없는 브라우저다(만들면 예외 — 휴대폰 Chrome 등)
|
||||
*/
|
||||
export type DesktopNotificationResult = { status: 'shown'; close: () => void } | { status: 'not-allowed' | 'unsupported' }
|
||||
|
||||
export function showDesktopNotification(input: DesktopNotificationInput): DesktopNotificationResult {
|
||||
if (currentNotificationPermission() !== 'granted') return { status: 'not-allowed' }
|
||||
const options: DesktopNotificationOptions = {
|
||||
body: input.body,
|
||||
tag: DESKTOP_NOTIFY_TAG,
|
||||
renotify: true,
|
||||
icon: notificationIconUrl,
|
||||
}
|
||||
let notification: Notification
|
||||
try {
|
||||
notification = new Notification(input.title, options)
|
||||
} catch {
|
||||
// 페이지에서 바로 띄우지 못한다(서비스 워커만 — 휴대폰 Chrome 등)
|
||||
return { status: 'unsupported' }
|
||||
}
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
notification.close()
|
||||
input.onClick?.()
|
||||
}
|
||||
return { status: 'shown', close: () => notification.close() }
|
||||
}
|
||||
@@ -60,6 +60,19 @@ export function useMailNavigation() {
|
||||
await router.replace(mailboxId ? mailboxLocation(mailboxId) : { name: 'mail' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 바탕화면 알림을 눌렀을 때 (디자인 README "5단계 › 바탕화면 알림") — 받은편지함의 그 메일, 여러 통이면(messageId 없음) 받은편지함.
|
||||
* 사용자가 고른 이동이라 기록에 쌓는다 — 목록에서 메일을 여는 것과 같다 (이미 그 화면이면 그대로).
|
||||
* 받은편지함을 모르면 /mail 로 — /mail 은 메일함을 받은 뒤 기본 메일함(받은편지함)으로 바꿔 끼운다
|
||||
*/
|
||||
async function openFromNotification(inboxId: string | null, messageId: string | null): Promise<void> {
|
||||
if (!inboxId) {
|
||||
await router.push({ name: 'mail' })
|
||||
return
|
||||
}
|
||||
await router.push(messageId ? messageLocation(inboxId, messageId) : mailboxLocation(inboxId))
|
||||
}
|
||||
|
||||
return {
|
||||
mailboxLocation,
|
||||
messageLocation,
|
||||
@@ -69,5 +82,6 @@ export function useMailNavigation() {
|
||||
showSibling,
|
||||
showSiblingIn,
|
||||
replaceWithMailbox,
|
||||
openFromNotification,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,889 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { effectScope, watch, type EffectScope } from 'vue'
|
||||
|
||||
import {
|
||||
isUserViewingPage,
|
||||
useMailRealtime,
|
||||
type MailRealtimeOptions,
|
||||
type NewMailContext,
|
||||
} from '@/composables/useMailRealtime'
|
||||
import {
|
||||
MAIL_EVENTS_CHANNEL_NAME,
|
||||
MAIL_EVENTS_SILENCE_MS,
|
||||
MAIL_EVENTS_STABLE_MS,
|
||||
MAIL_EVENTS_VISIBILITY_WAIT_MS,
|
||||
} from '@/shared/constants/mail-notify'
|
||||
import type { NewMailSignal } from '@/shared/utils/mail-events'
|
||||
import {
|
||||
mailEventsLockName,
|
||||
packRealtimeMessage,
|
||||
unpackRealtimeMessage,
|
||||
type MailRealtimeStatus,
|
||||
type RealtimeMessage,
|
||||
} from '@/shared/utils/mail-realtime'
|
||||
import { SSE_MAX_FIELD_LENGTH } from '@/shared/utils/sse-parser'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import { createEventStreamServer, type EventStreamServer } from '@/test-utils/event-stream'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
import { installFakeLocks, uninstallFakeLocks, type FakeLockManager } from '@/test-utils/web-locks'
|
||||
|
||||
// 실시간 새 메일의 연결 (계약 2절 "실시간 알림 (GET /api/mail-events)")
|
||||
// 가짜 서버(SSE) · 가짜 잠금(Web Locks) · 가짜 채널(BroadcastChannel) · 손으로 돌리는 시계로 확인한다 (고정 대기 없이).
|
||||
// 다시 잇는 간격의 흔들림은 가운데 값(0.5 — 흔들림 없음)으로 둔다. 서버 종료 뒤 흔들림(0~3초)은 그래서 1.5초다.
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const OTHER_USER = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
|
||||
/** 흔들림 0.5 일 때 서버 종료(end) 뒤 다시 잇기까지 */
|
||||
const SHUTDOWN_SPREAD_AT_HALF_MS = 1_500
|
||||
|
||||
interface Tab {
|
||||
scope: EffectScope
|
||||
pinia: Pinia
|
||||
realtime: ReturnType<typeof useMailRealtimeStore>
|
||||
auth: ReturnType<typeof useAuthStore>
|
||||
/** 연결 상태가 바뀐 차례 */
|
||||
statuses: MailRealtimeStatus[]
|
||||
}
|
||||
|
||||
describe('useMailRealtime (실시간 연결)', () => {
|
||||
let clock: ManualClock
|
||||
let server: EventStreamServer
|
||||
let locks: FakeLockManager
|
||||
let tabs: Tab[]
|
||||
let apiRequests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
clock = createManualClock()
|
||||
server = createEventStreamServer()
|
||||
locks = installFakeLocks()
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
tabs = []
|
||||
apiRequests = installMockApi(({ url }) => (url === '/auth/refresh' ? ok(USER) : ok(null)))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
tabs.forEach((tab) => tab.scope.stop())
|
||||
FakeBroadcastChannel.closeAll()
|
||||
uninstallFakeLocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
restoreApiAdapter()
|
||||
useToastStore().clear()
|
||||
})
|
||||
|
||||
/** 탭 하나 — 탭마다 스토어(Pinia)가 따로다. 잠금 · 채널은 브라우저 하나를 함께 쓴다 */
|
||||
function openTab(options: MailRealtimeOptions = {}, user = USER): Tab {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.markAuthenticated(user)
|
||||
const scope = effectScope()
|
||||
const realtime = useMailRealtimeStore(pinia)
|
||||
const statuses: MailRealtimeStatus[] = []
|
||||
scope.run(() => {
|
||||
watch(
|
||||
() => realtime.status,
|
||||
(status) => statuses.push(status),
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
useMailRealtime({ clock, random: () => 0.5, fetch: server.fetch, isUserViewing: () => false, ...options })
|
||||
})
|
||||
const tab: Tab = { scope, pinia, realtime, auth, statuses }
|
||||
tabs.push(tab)
|
||||
return tab
|
||||
}
|
||||
|
||||
/** 잠금 · 응답 · 채널 전달(다음 작업)이 끝나기를 몇 번 양보해 기다린다 */
|
||||
async function settle() {
|
||||
for (let round = 0; round < 6; round += 1) await flushPromises()
|
||||
}
|
||||
|
||||
async function tick(ms: number) {
|
||||
clock.advance(ms)
|
||||
await settle()
|
||||
}
|
||||
|
||||
/** 같은 브라우저의 다른 탭이 쓰는 채널 — 받은 메시지를 모은다 */
|
||||
function rawTab(email = USER.email) {
|
||||
const channel = new FakeBroadcastChannel(MAIL_EVENTS_CHANNEL_NAME)
|
||||
const received: RealtimeMessage[] = []
|
||||
channel.addEventListener('message', (event) => {
|
||||
const message = unpackRealtimeMessage((event as MessageEvent<unknown>).data, email)
|
||||
if (message) received.push(message)
|
||||
})
|
||||
return {
|
||||
channel,
|
||||
received,
|
||||
post: (message: RealtimeMessage, from = email) => channel.postMessage(packRealtimeMessage(from, message)),
|
||||
}
|
||||
}
|
||||
|
||||
/** 다른 탭이 잠금을 쥐고 있다 — 돌려준 함수로 놓는다(그 탭이 stop 없이 닫힌 것처럼) */
|
||||
function holdLockElsewhere(): () => void {
|
||||
let release: () => void = () => undefined
|
||||
void navigator.locks.request(
|
||||
mailEventsLockName(USER.email),
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
return () => release()
|
||||
}
|
||||
|
||||
/** 연결이 자리 잡게 한다 — 첫 state 와 정상 ping */
|
||||
async function openAndSettle() {
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
server.last().emit('ping', { upstream: true })
|
||||
await settle()
|
||||
}
|
||||
|
||||
const refreshCount = () => apiRequests.filter((request) => request.url === '/auth/refresh').length
|
||||
|
||||
it('로그인한 탭이 잠금을 받아 /api/mail-events 를 fetch 로 연다 — 같은 주소 · 쿠키 · SSE', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
expect(server.connections).toHaveLength(1)
|
||||
const [connection] = server.connections
|
||||
expect(connection?.url).toBe('/api/mail-events')
|
||||
expect(connection?.init).toMatchObject({
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
})
|
||||
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(true)
|
||||
expect(tab.realtime.status).toBe('connecting')
|
||||
})
|
||||
|
||||
it('state 를 받으면 live — 처음 값은 기준, 바뀌면 메일 화면이 맞출 일 · 받은편지함의 안 읽은 수', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 2 })
|
||||
await settle()
|
||||
expect(tab.realtime.status).toBe('live')
|
||||
expect(tab.realtime.pendingSync).toBe(false)
|
||||
|
||||
server.last().emit('state', { state: 's2', inboxUnread: 3 })
|
||||
await settle()
|
||||
expect(tab.realtime.pendingSync).toBe(true)
|
||||
expect(tab.realtime.inboxUnread).toBe(3)
|
||||
})
|
||||
|
||||
it('조각으로 와도 · 주석 줄이 섞여도 읽는다', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
server.last().send(': hello\n\nevent: sta')
|
||||
server.last().send('te\r\ndata: {"state":"s1","inboxUnread":0}\r')
|
||||
server.last().send('\n\r\n')
|
||||
await settle()
|
||||
|
||||
expect(tab.realtime.status).toBe('live')
|
||||
})
|
||||
|
||||
it('ping.upstream 이 false 면 끊김(1분 확인으로) — 연결은 그대로 두고, true 가 오면 다시 live', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
|
||||
server.last().emit('ping', { upstream: false })
|
||||
await settle()
|
||||
expect(tab.realtime.status).toBe('down')
|
||||
expect(server.last().aborted).toBe(false)
|
||||
|
||||
server.last().emit('ping', { upstream: true })
|
||||
await settle()
|
||||
expect(tab.realtime.status).toBe('live')
|
||||
expect(server.connections).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('다시 잇기', () => {
|
||||
it('실패(5xx · 429 · 네트워크)는 2 · 5 · 15 · 30 · 60초 간격으로 다시 잇는다 — 자리 잡으면 처음 간격부터', async () => {
|
||||
server.reply(
|
||||
{ kind: 'status', status: 503 },
|
||||
{ kind: 'network' },
|
||||
{ kind: 'status', status: 429 },
|
||||
{ kind: 'status', status: 502 },
|
||||
{ kind: 'status', status: 503 },
|
||||
{ kind: 'status', status: 503 },
|
||||
)
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
expect(server.connections).toHaveLength(1)
|
||||
expect(tab.realtime.status).toBe('down')
|
||||
|
||||
for (const [delay, total] of [
|
||||
[2_000, 2],
|
||||
[5_000, 3],
|
||||
[15_000, 4],
|
||||
[30_000, 5],
|
||||
[60_000, 6],
|
||||
[60_000, 7],
|
||||
] as const) {
|
||||
await tick(delay - 1)
|
||||
expect(server.connections).toHaveLength(total - 1)
|
||||
await tick(1)
|
||||
expect(server.connections).toHaveLength(total)
|
||||
}
|
||||
|
||||
// 일곱째는 열리고 자리 잡는다(정상 ping) — 그 뒤 끊기면 다시 2초부터
|
||||
await openAndSettle()
|
||||
expect(tab.realtime.status).toBe('live')
|
||||
server.last().fail()
|
||||
await settle()
|
||||
expect(tab.realtime.status).toBe('down')
|
||||
await tick(1_999)
|
||||
expect(server.connections).toHaveLength(7)
|
||||
await tick(1)
|
||||
expect(server.connections).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('서버가 중간에 죽으면(읽기 오류) end 가 아니라 보통의 실패다 — 바로가 아니라 2초 뒤에', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
|
||||
server.last().fail()
|
||||
await settle()
|
||||
expect(tab.realtime.status).toBe('down')
|
||||
expect(server.connections).toHaveLength(1)
|
||||
|
||||
await tick(2_000)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('서버가 그냥 닫아도(end 없이) 보통의 실패다', async () => {
|
||||
openTab()
|
||||
await settle()
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
|
||||
server.last().close()
|
||||
await settle()
|
||||
expect(server.connections).toHaveLength(1)
|
||||
await tick(2_000)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('state 뒤 바로 닫는 서버 — 자리 잡기 전에 끊기면 간격이 2 · 5 · 15초로 늘어난다 (2초마다 되풀이하지 않는다)', async () => {
|
||||
openTab()
|
||||
await settle()
|
||||
|
||||
for (const [delay, total] of [
|
||||
[2_000, 2],
|
||||
[5_000, 3],
|
||||
[15_000, 4],
|
||||
] as const) {
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
server.last().close()
|
||||
await settle()
|
||||
await tick(delay - 1)
|
||||
expect(server.connections).toHaveLength(total - 1)
|
||||
await tick(1)
|
||||
expect(server.connections).toHaveLength(total)
|
||||
}
|
||||
})
|
||||
|
||||
it('정상 ping 이 없어도 20초 이어지면 자리 잡은 것이다 — 그 뒤 끊기면 2초부터', async () => {
|
||||
server.reply({ kind: 'status', status: 503 }, { kind: 'status', status: 503 })
|
||||
openTab()
|
||||
await settle()
|
||||
await tick(2_000)
|
||||
await tick(5_000)
|
||||
expect(server.connections).toHaveLength(3)
|
||||
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
// 연결 직후의 upstream: false 알림은 자리 잡은 것으로 세지 않는다
|
||||
server.last().emit('ping', { upstream: false })
|
||||
await settle()
|
||||
await tick(MAIL_EVENTS_STABLE_MS)
|
||||
server.last().close()
|
||||
await settle()
|
||||
await tick(2_000)
|
||||
expect(server.connections).toHaveLength(4)
|
||||
|
||||
// 자리 잡기 전(20초 안)에 끊기면 이어서 센다 — 다음은 5초
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
await tick(MAIL_EVENTS_STABLE_MS - 1)
|
||||
server.last().close()
|
||||
await settle()
|
||||
await tick(4_999)
|
||||
expect(server.connections).toHaveLength(4)
|
||||
await tick(1)
|
||||
expect(server.connections).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('text/event-stream 이 아니면(프록시가 index.html 을 준 경우 등) 실패로 본다', async () => {
|
||||
server.reply({ kind: 'stream', contentType: 'text/html' })
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
expect(tab.realtime.status).toBe('down')
|
||||
await tick(2_000)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('줄 끝 없이 1MB 를 넘게 보내면 실패로 끊고 다시 잇는다', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
|
||||
server.last().send(`data: ${'x'.repeat(SSE_MAX_FIELD_LENGTH)}`)
|
||||
await settle()
|
||||
|
||||
expect(server.connections[0]?.aborted).toBe(true)
|
||||
expect(tab.realtime.status).toBe('down')
|
||||
await tick(2_000)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('자리 잡은 연결이 end(서버 종료)로 끝나면 0~3초 흔들어 다시 잇는다 — 끊김으로 알리지 않는다', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
await openAndSettle()
|
||||
|
||||
server.last().emit('end', { reason: 'server-shutdown' })
|
||||
await settle()
|
||||
expect(server.connections[0]?.aborted).toBe(true)
|
||||
await tick(SHUTDOWN_SPREAD_AT_HALF_MS - 1)
|
||||
expect(server.connections).toHaveLength(1)
|
||||
await tick(1)
|
||||
|
||||
expect(server.connections).toHaveLength(2)
|
||||
expect(tab.realtime.status).toBe('live')
|
||||
expect(tab.statuses).not.toContain('down')
|
||||
expect(refreshCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('자리 잡은 연결의 end 가 token-expiring 이면 세션을 갱신한 뒤 바로 다시 잇는다', async () => {
|
||||
openTab()
|
||||
await settle()
|
||||
await openAndSettle()
|
||||
|
||||
server.last().emit('end', { reason: 'token-expiring' })
|
||||
await settle()
|
||||
|
||||
expect(refreshCount()).toBe(1)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('열리기 전(첫 state 전)에 온 end 는 실패처럼 기다린다', async () => {
|
||||
openTab()
|
||||
await settle()
|
||||
|
||||
server.last().emit('end', { reason: 'server-shutdown' })
|
||||
await settle()
|
||||
expect(server.connections).toHaveLength(1)
|
||||
await tick(2_000)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('state 뒤 바로 end(token-expiring)를 보내는 서버(세션 끝 무렵) — 갱신하지 않고 간격이 늘어난다', async () => {
|
||||
openTab()
|
||||
await settle()
|
||||
|
||||
for (const [delay, total] of [
|
||||
[2_000, 2],
|
||||
[5_000, 3],
|
||||
[15_000, 4],
|
||||
] as const) {
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
server.last().emit('end', { reason: 'token-expiring' })
|
||||
await settle()
|
||||
expect(server.connections).toHaveLength(total - 1)
|
||||
await tick(delay)
|
||||
expect(server.connections).toHaveLength(total)
|
||||
}
|
||||
expect(refreshCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('갱신한 바로 뒤의 연결이 ping 전에 또 token-expiring 이면 다시 갱신하지 않고 기다린다', async () => {
|
||||
openTab()
|
||||
await settle()
|
||||
await openAndSettle()
|
||||
server.last().emit('end', { reason: 'token-expiring' })
|
||||
await settle()
|
||||
expect(refreshCount()).toBe(1)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
server.last().emit('end', { reason: 'token-expiring' })
|
||||
await settle()
|
||||
expect(refreshCount()).toBe(1)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
await tick(2_000)
|
||||
expect(server.connections).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('75초 동안 아무것도 오지 않으면 끊고 다시 잇는다 — 머리글이 오지 않는 연결도 fetch 를 시작할 때부터 센다', async () => {
|
||||
server.reply({ kind: 'hang' })
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
await tick(MAIL_EVENTS_SILENCE_MS - 1)
|
||||
expect(server.connections[0]?.aborted).toBe(false)
|
||||
await tick(1)
|
||||
expect(server.connections[0]?.aborted).toBe(true)
|
||||
expect(tab.realtime.status).toBe('down')
|
||||
await tick(2_000)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
|
||||
// 열린 뒤에도 같다 — 마지막 신호에서 75초 (그만큼 이어졌으니 자리 잡은 것 — 다음은 2초)
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
await tick(MAIL_EVENTS_SILENCE_MS - 1)
|
||||
expect(tab.realtime.status).toBe('live')
|
||||
await tick(1)
|
||||
expect(tab.realtime.status).toBe('down')
|
||||
expect(server.connections[1]?.aborted).toBe(true)
|
||||
await tick(2_000)
|
||||
expect(server.connections).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('ping 이 오면 75초를 다시 센다', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
|
||||
await tick(50_000)
|
||||
server.last().emit('ping', { upstream: true })
|
||||
await settle()
|
||||
await tick(50_000)
|
||||
|
||||
expect(tab.realtime.status).toBe('live')
|
||||
expect(server.connections).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('401 · 세션', () => {
|
||||
it('401 이면 세션을 한 번 갱신하고 바로 다시 잇는다', async () => {
|
||||
server.reply({ kind: 'status', status: 401 })
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
expect(refreshCount()).toBe(1)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
expect(tab.realtime.status).toBe('live')
|
||||
expect(tab.auth.isLoggedIn).toBe(true)
|
||||
})
|
||||
|
||||
it('갱신도 세션 끝(401)으로 실패하면 지금의 세션 만료 흐름 — 다시 잇지 않는다', async () => {
|
||||
server.reply({ kind: 'status', status: 401 })
|
||||
restoreApiAdapter()
|
||||
apiRequests = installMockApi(() => fail(401, 'AUTH_001'))
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
expect(tab.auth.sessionExpired).toBe(true)
|
||||
expect(tab.realtime.status).toBe('off')
|
||||
await tick(120_000)
|
||||
expect(server.connections).toHaveLength(1)
|
||||
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
|
||||
})
|
||||
|
||||
it('갱신한 바로 뒤의 다시 잇기도 401 이면 세션 만료 (axios 의 401 재시도와 같다)', async () => {
|
||||
server.reply({ kind: 'status', status: 401 }, { kind: 'status', status: 401 })
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
expect(refreshCount()).toBe(1)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
expect(tab.auth.sessionExpired).toBe(true)
|
||||
})
|
||||
|
||||
it('갱신 뒤의 다시 잇기가 401 이 아닌 까닭(503)으로 끝나면 "갱신 직후"를 지운다 — 나중의 401 은 다시 갱신하고 만료하지 않는다', async () => {
|
||||
server.reply({ kind: 'status', status: 401 }, { kind: 'status', status: 503 }, { kind: 'status', status: 401 })
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
expect(refreshCount()).toBe(1)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
|
||||
await tick(2_000)
|
||||
|
||||
expect(server.connections).toHaveLength(4)
|
||||
expect(refreshCount()).toBe(2)
|
||||
expect(tab.auth.sessionExpired).toBe(false)
|
||||
expect(tab.auth.isLoggedIn).toBe(true)
|
||||
})
|
||||
|
||||
it('갱신이 장애(503)로 실패하면 세션은 그대로 두고 기다렸다 다시 — 그 뒤의 401 은 다시 갱신해 본다', async () => {
|
||||
server.reply({ kind: 'status', status: 401 }, { kind: 'status', status: 401 })
|
||||
restoreApiAdapter()
|
||||
const refreshReplies = [fail(503, 'MAIL_001'), ok(USER)]
|
||||
apiRequests = installMockApi(() => refreshReplies.shift() ?? ok(USER))
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
expect(refreshCount()).toBe(1)
|
||||
expect(tab.auth.isLoggedIn).toBe(true)
|
||||
expect(tab.realtime.status).toBe('down')
|
||||
|
||||
await tick(2_000)
|
||||
expect(refreshCount()).toBe(2)
|
||||
expect(server.connections).toHaveLength(3)
|
||||
expect(tab.auth.sessionExpired).toBe(false)
|
||||
})
|
||||
|
||||
it('로그아웃하면 잠금을 놓고 연결을 닫는다 — 다시 로그인하면 새로 잇는다', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
|
||||
tab.auth.clearAuth()
|
||||
await settle()
|
||||
expect(server.connections[0]?.aborted).toBe(true)
|
||||
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
|
||||
expect(tab.realtime.status).toBe('off')
|
||||
await tick(120_000)
|
||||
expect(server.connections).toHaveLength(1)
|
||||
|
||||
tab.auth.startSession(USER)
|
||||
await settle()
|
||||
expect(server.connections).toHaveLength(2)
|
||||
expect(tab.realtime.status).toBe('connecting')
|
||||
})
|
||||
|
||||
it('다른 계정으로 로그인하면 그 계정의 잠금으로 새로 잇는다', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
tab.auth.startSession(OTHER_USER)
|
||||
await settle()
|
||||
|
||||
expect(server.connections[0]?.aborted).toBe(true)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
|
||||
expect(locks.isHeld(mailEventsLockName(OTHER_USER.email))).toBe(true)
|
||||
})
|
||||
|
||||
it('토큰 갱신(같은 세션)으로는 다시 잇지 않는다', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
tab.auth.markAuthenticated({ ...USER })
|
||||
await settle()
|
||||
|
||||
expect(server.connections).toHaveLength(1)
|
||||
expect(server.connections[0]?.aborted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('브라우저마다 연결 하나 (Web Locks · BroadcastChannel)', () => {
|
||||
it('두 번째 탭은 연결하지 않고 잠금을 기다리며, 첫 탭이 나눈 소식으로 같은 상태가 된다', async () => {
|
||||
const first = openTab()
|
||||
await settle()
|
||||
const second = openTab()
|
||||
await settle()
|
||||
|
||||
expect(server.connections).toHaveLength(1)
|
||||
expect(locks.waitingCount(mailEventsLockName(USER.email))).toBe(1)
|
||||
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 1 })
|
||||
await settle()
|
||||
expect(first.realtime.status).toBe('live')
|
||||
expect(second.realtime.status).toBe('live')
|
||||
|
||||
server.last().emit('state', { state: 's2', inboxUnread: 2 })
|
||||
await settle()
|
||||
expect(first.realtime.pendingSync).toBe(true)
|
||||
expect(second.realtime.pendingSync).toBe(true)
|
||||
expect(second.realtime.inboxUnread).toBe(2)
|
||||
|
||||
server.last().emit('ping', { upstream: false })
|
||||
await settle()
|
||||
expect(second.realtime.status).toBe('down')
|
||||
})
|
||||
|
||||
it('연결한 탭을 닫으면 다른 탭이 잠금을 받아 이어서 연결한다', async () => {
|
||||
const first = openTab()
|
||||
await settle()
|
||||
const second = openTab()
|
||||
await settle()
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 1 })
|
||||
await settle()
|
||||
|
||||
first.scope.stop()
|
||||
await settle()
|
||||
|
||||
expect(server.connections[0]?.aborted).toBe(true)
|
||||
expect(server.connections).toHaveLength(2)
|
||||
// 이어받은 탭은 자기 연결이 열릴 때까지 "잇는 중" (앞 탭의 소식으로 정한 상태는 버린다)
|
||||
expect(second.realtime.status).toBe('connecting')
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 1 })
|
||||
await settle()
|
||||
expect(second.realtime.status).toBe('live')
|
||||
})
|
||||
|
||||
it('연결한 탭이 stop 없이 닫혀도(탭 닫기 · 새로 고침 · 폐기 — 잠금만 넘어간다) 이어받은 탭이 끊김을 나눠 다른 탭이 1분 확인으로', async () => {
|
||||
const release = holdLockElsewhere()
|
||||
const closingLeader = rawTab()
|
||||
const second = openTab()
|
||||
const third = openTab()
|
||||
await settle()
|
||||
closingLeader.post({ type: 'state', state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
expect(second.realtime.status).toBe('live')
|
||||
expect(third.realtime.status).toBe('live')
|
||||
|
||||
// 브라우저가 앞 탭을 없앴다 — 채널도 닫히고 아무것도 보내지 않는다
|
||||
server.reply({ kind: 'hang' })
|
||||
closingLeader.channel.close()
|
||||
release()
|
||||
await settle()
|
||||
|
||||
expect(server.connections).toHaveLength(1)
|
||||
const leader = [second, third].find((tab) => tab.realtime.status === 'connecting')
|
||||
const follower = [second, third].find((tab) => tab !== leader)
|
||||
expect(leader).toBeDefined()
|
||||
expect(follower?.realtime.status).toBe('down')
|
||||
})
|
||||
|
||||
it('연결한 탭이 닫힐 때(pagehide) 끊김을 나눈다', async () => {
|
||||
const other = rawTab()
|
||||
openTab()
|
||||
await settle()
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
other.received.length = 0
|
||||
|
||||
window.dispatchEvent(new Event('pagehide'))
|
||||
await settle()
|
||||
|
||||
expect(other.received).toEqual([{ type: 'down' }])
|
||||
})
|
||||
|
||||
it('새로 연 탭은 hello 로 지금 상태를 받는다 — 마지막 state 를 기준으로 삼는다', async () => {
|
||||
openTab()
|
||||
await settle()
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 4 })
|
||||
await settle()
|
||||
|
||||
const second = openTab()
|
||||
await settle()
|
||||
|
||||
expect(second.realtime.status).toBe('live')
|
||||
expect(second.realtime.observeState('s1')).toBe(false)
|
||||
expect(second.realtime.observeState('s2')).toBe(true)
|
||||
})
|
||||
|
||||
it('끊긴 채 hello 에 답할 때는 기준값(baseline)과 끊김만 — 다른 탭이 live 로 깜빡이지 않는다', async () => {
|
||||
openTab()
|
||||
await settle()
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
const second = openTab()
|
||||
await settle()
|
||||
server.reply({ kind: 'status', status: 503 }, { kind: 'status', status: 503 })
|
||||
server.last().fail()
|
||||
await settle()
|
||||
expect(second.realtime.status).toBe('down')
|
||||
second.statuses.length = 0
|
||||
|
||||
const third = openTab()
|
||||
await settle()
|
||||
|
||||
expect(second.statuses).toEqual([])
|
||||
expect(third.realtime.status).toBe('down')
|
||||
expect(third.statuses).not.toContain('live')
|
||||
expect(third.realtime.observeState('s1')).toBe(false)
|
||||
})
|
||||
|
||||
it('기다리는 탭은 75초 동안 소식이 없으면 끊김으로 본다 (연결한 탭이 멈췄을 때)', async () => {
|
||||
const holder = rawTab()
|
||||
holdLockElsewhere()
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
holder.post({ type: 'state', state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
expect(tab.realtime.status).toBe('live')
|
||||
|
||||
await tick(MAIL_EVENTS_SILENCE_MS)
|
||||
expect(tab.realtime.status).toBe('down')
|
||||
expect(server.connections).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('다른 계정의 소식은 버린다', async () => {
|
||||
const holder = rawTab()
|
||||
holdLockElsewhere()
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
holder.post({ type: 'state', state: 's1', inboxUnread: 0 }, OTHER_USER.email)
|
||||
await settle()
|
||||
expect(tab.realtime.status).toBe('connecting')
|
||||
|
||||
holder.post({ type: 'state', state: 's1', inboxUnread: 0 })
|
||||
await settle()
|
||||
expect(tab.realtime.status).toBe('live')
|
||||
})
|
||||
|
||||
it('연결한 탭은 받은 소식을 사용자 주소와 함께 다른 탭에 나눈다 — 연결하기 전에 끊김을 한 번', async () => {
|
||||
const other = rawTab()
|
||||
openTab()
|
||||
await settle()
|
||||
|
||||
server.last().emit('state', { state: 's1', inboxUnread: 2 })
|
||||
server.last().emit('ping', { upstream: true })
|
||||
await settle()
|
||||
|
||||
expect(other.received).toEqual([
|
||||
{ type: 'hello' },
|
||||
{ type: 'down' },
|
||||
{ type: 'state', state: 's1', inboxUnread: 2 },
|
||||
{ type: 'ping', upstream: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('Web Locks 가 없는 브라우저는 실시간 없이 1분 확인만 한다', async () => {
|
||||
uninstallFakeLocks()
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
|
||||
expect(tab.realtime.status).toBe('unsupported')
|
||||
expect(server.connections).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('새 메일 · 사용자가 보고 있는 탭 (연결한 탭)', () => {
|
||||
it('new-mail 을 onNewMail 에 넘긴다 — 사용자가 보고 있는 탭이 있는지 다른 탭에 묻는 길과 함께', async () => {
|
||||
const onNewMail = vi.fn<(signal: NewMailSignal, context: NewMailContext) => void>()
|
||||
openTab({ onNewMail })
|
||||
await settle()
|
||||
|
||||
server.last().emit('new-mail', {
|
||||
count: 1,
|
||||
messages: [{ id: 'm1', from: { name: '박서준', email: 'seojun.park@arasystem.test' }, subject: '견적서', receivedAt: '' }],
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(onNewMail).toHaveBeenCalledTimes(1)
|
||||
const [signal, context] = onNewMail.mock.calls[0] ?? []
|
||||
expect(signal).toEqual({
|
||||
count: 1,
|
||||
messages: [{ id: 'm1', from: { name: '박서준', email: 'seojun.park@arasystem.test' }, subject: '견적서', receivedAt: '' }],
|
||||
})
|
||||
expect(context?.isUserViewing()).toBe(false)
|
||||
})
|
||||
|
||||
it('보고 있는 탭 묻기 — 그 탭이 답하면 true, 답이 없으면 기다린 뒤 false', async () => {
|
||||
const contexts: NewMailContext[] = []
|
||||
openTab({ onNewMail: (_signal, context) => contexts.push(context) })
|
||||
await settle()
|
||||
server.last().emit('new-mail', { count: 1, messages: [{ id: 'm1' }] })
|
||||
await settle()
|
||||
const context = contexts[0]
|
||||
if (!context) throw new Error('새 메일을 받지 못했다')
|
||||
|
||||
// 답이 없다 — 기다린 뒤 false
|
||||
const silent = context.askOtherTabsViewing()
|
||||
let answered: boolean | null = null
|
||||
void silent.then((value) => {
|
||||
answered = value
|
||||
})
|
||||
await tick(MAIL_EVENTS_VISIBILITY_WAIT_MS - 1)
|
||||
expect(answered).toBeNull()
|
||||
await tick(1)
|
||||
expect(answered).toBe(false)
|
||||
|
||||
// 보고 있는 탭이 답한다
|
||||
const viewingTab = rawTab()
|
||||
viewingTab.channel.addEventListener('message', (event) => {
|
||||
const message = unpackRealtimeMessage((event as MessageEvent<unknown>).data, USER.email)
|
||||
if (message?.type === 'visibility-query') viewingTab.post({ type: 'visibility-reply', id: message.id })
|
||||
})
|
||||
const asked = context.askOtherTabsViewing()
|
||||
await settle()
|
||||
await expect(asked).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('기다리는 탭은 사용자가 보고 있을 때만 묻는 말에 답한다', async () => {
|
||||
let viewing = false
|
||||
const holder = rawTab()
|
||||
holdLockElsewhere()
|
||||
openTab({ isUserViewing: () => viewing })
|
||||
await settle()
|
||||
|
||||
holder.post({ type: 'visibility-query', id: 'q1' })
|
||||
await settle()
|
||||
expect(holder.received.filter((message) => message.type === 'visibility-reply')).toEqual([])
|
||||
|
||||
viewing = true
|
||||
holder.post({ type: 'visibility-query', id: 'q2' })
|
||||
await settle()
|
||||
expect(holder.received.filter((message) => message.type === 'visibility-reply')).toEqual([
|
||||
{ type: 'visibility-reply', id: 'q2' },
|
||||
])
|
||||
})
|
||||
|
||||
it('"보고 있음"은 보이고 초점이 있을 때 — 두 번째 모니터에 띄워 두고 다른 창에서 일하면 보고 있지 않다', () => {
|
||||
let visibility: DocumentVisibilityState = 'visible'
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
|
||||
const hasFocus = vi.spyOn(document, 'hasFocus')
|
||||
try {
|
||||
hasFocus.mockReturnValue(true)
|
||||
expect(isUserViewingPage()).toBe(true)
|
||||
hasFocus.mockReturnValue(false)
|
||||
expect(isUserViewingPage()).toBe(false)
|
||||
visibility = 'hidden'
|
||||
hasFocus.mockReturnValue(true)
|
||||
expect(isUserViewingPage()).toBe(false)
|
||||
} finally {
|
||||
Reflect.deleteProperty(document, 'visibilityState')
|
||||
}
|
||||
})
|
||||
|
||||
it('사용자가 연결한 탭으로 돌아오면(초점) onUserReturn — 띄운 바탕화면 알림을 닫는다', async () => {
|
||||
const onUserReturn = vi.fn()
|
||||
let viewing = false
|
||||
openTab({ onUserReturn, isUserViewing: () => viewing })
|
||||
await settle()
|
||||
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
expect(onUserReturn).not.toHaveBeenCalled()
|
||||
|
||||
viewing = true
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
expect(onUserReturn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('다른 탭으로 돌아오면 그 탭이 attended 를 보내고 연결한 탭이 onUserReturn', async () => {
|
||||
const onUserReturn = vi.fn()
|
||||
const other = rawTab()
|
||||
openTab({ onUserReturn })
|
||||
await settle()
|
||||
|
||||
other.post({ type: 'attended' })
|
||||
await settle()
|
||||
|
||||
expect(onUserReturn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('기다리는 탭으로 돌아오면 연결한 탭에 attended 를 보낸다', async () => {
|
||||
const holder = rawTab()
|
||||
holdLockElsewhere()
|
||||
openTab({ isUserViewing: () => true })
|
||||
await settle()
|
||||
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await settle()
|
||||
|
||||
expect(holder.received.filter((message) => message.type === 'attended')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,545 @@
|
||||
import { onScopeDispose, watch } from 'vue'
|
||||
|
||||
import { buildApiUrl, isSessionEnd, refreshSession } from './useApi'
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import {
|
||||
MAIL_EVENTS_CHANNEL_NAME,
|
||||
MAIL_EVENTS_CONTENT_TYPE,
|
||||
MAIL_EVENTS_END_TOKEN_EXPIRING,
|
||||
MAIL_EVENTS_PATH,
|
||||
MAIL_EVENTS_SHUTDOWN_SPREAD_MS,
|
||||
MAIL_EVENTS_SILENCE_MS,
|
||||
MAIL_EVENTS_STABLE_MS,
|
||||
MAIL_EVENTS_VISIBILITY_WAIT_MS,
|
||||
} from '@/shared/constants/mail-notify'
|
||||
import { supportsMailRealtime } from '@/shared/utils/browser-support'
|
||||
import { parseMailEvent, type MailServerEvent, type NewMailSignal } from '@/shared/utils/mail-events'
|
||||
import {
|
||||
mailEventsLockName,
|
||||
packRealtimeMessage,
|
||||
reconnectDelayMs,
|
||||
unpackRealtimeMessage,
|
||||
type RealtimeMessage,
|
||||
} from '@/shared/utils/mail-realtime'
|
||||
import { createSseParser } from '@/shared/utils/sse-parser'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailRealtimeStore, type MailRealtimeStatus } from '@/stores/mail-realtime.store'
|
||||
|
||||
// 실시간 새 메일 — 계약 2절 "실시간 알림 (GET /api/mail-events)" 의 클라이언트. App.vue 가 한 번 부르고, 로그인한 동안 앱 전체에서 돈다
|
||||
// (설정 등 다른 화면에서도 바탕화면 알림 · 연결 상태가 이어지게 — 메일 화면이 할 일은 useMailStatePolling 이 이 소식을 받아서 한다).
|
||||
//
|
||||
// 브라우저마다 연결 하나 — 탭마다 열면 HTTP/1.1 의 주소당 연결 6개를 금방 다 써서 다른 요청이 멈춘다.
|
||||
// - 잠금(navigator.locks — 이름에 사용자 주소)을 받은 탭 하나만 연결한다. 그 탭을 닫으면 잠금이 풀려 다른 탭이 이어받는다
|
||||
// - 연결한 탭은 받은 소식(state · ping · 끊김)을 BroadcastChannel 로 다른 탭에 나눈다. 메시지에 사용자 주소를 넣고 다른 계정의 것은 버린다
|
||||
// - 모든 탭(연결한 탭 포함)이 같은 규칙을 탄다: 75초 안에 ping · state 가 오고 upstream 이 true 면 live(1분 확인을 멈춘다), 아니면 down.
|
||||
// state 는 마지막으로 본 값과 비교해 바뀌었으면 메일 화면이 맞출 일로 남긴다 (stores/mail-realtime.store.ts)
|
||||
// - 잠금을 받은 탭은 연결하기 전에 down 을 한 번 나눈다 — 앞 탭이 stop 없이 닫혔어도(탭 닫기 · 새로 고침 · 폐기) 다른 탭이 1분 확인으로
|
||||
// 돌아가게. 연결한 탭이 닫힐 때(pagehide)도 나눈다
|
||||
// - 새로 연 탭은 hello 를 보내고, 연결한 탭은 지금 상태를 다시 보낸다 — 끊긴 채면 살아 있다는 신호로 세지 않는 기준값(baseline)과 끊김만
|
||||
// - Web Locks · BroadcastChannel · fetch 스트림 가운데 하나라도 없으면 실시간 없이 지금의 1분 확인만 한다 (status: unsupported)
|
||||
//
|
||||
// 연결 (연결한 탭)
|
||||
// - fetch 로 읽는다 — 브라우저의 EventSource 는 401 을 알 수 없어 세션을 갱신할 수 없다. 같은 주소라 쿠키가 함께 간다
|
||||
// - 401 → 세션을 한 번 갱신하고(refreshSession) 바로 다시 잇는다. 그 다시 잇기(바로 다음 시도)도 401 이면 세션 만료 — axios 의 401 재시도와 같다.
|
||||
// 다음 시도가 401 이 아닌 까닭으로 끝나면 "갱신 직후"는 지운다(나중의 401 은 다시 갱신해 본다).
|
||||
// 갱신이 세션 끝(401)으로 실패해도 세션 만료 흐름(대화상자). 갱신이 장애(5xx · 네트워크)로 실패하면 아래처럼 기다렸다 다시
|
||||
// - 그 밖의 실패(연결 안 됨 · 5xx · 429 · 스트림이 끊김 · 읽기 오류 · text/event-stream 이 아님 · 너무 긴 줄) → 2 · 5 · 15 · 30 · 60초(±20%) 뒤 다시.
|
||||
// 연결이 자리 잡으면(첫 state 뒤 정상 ping · 20초) 처음 간격부터 다시 센다 — 열리자마자 끊기는 연결은 간격이 계속 늘어난다
|
||||
// - end — 자리 잡은 연결이면 다시 잇는다: token-expiring 은 세션을 갱신한 뒤 바로, 그 밖(서버 종료 · 재시작)은 0~3초 흔들어서.
|
||||
// 자리 잡기 전에 온 end 는 까닭과 상관없이 실패처럼 기다린다 — 세션 끝 무렵 서버가 state 뒤 바로 end(token-expiring) 해도
|
||||
// 갱신 · 연결을 왕복 시간 속도로 되풀이해 갱신 한도를 다 쓰지 않게 (2026-10-28 리뷰)
|
||||
// - 75초 동안 아무것도 오지 않으면(머리글을 기다리는 동안 포함) 끊긴 것으로 보고 연결을 끊은 뒤 위처럼 다시 잇는다
|
||||
// - new-mail 은 연결한 탭만 받는다 — onNewMail 이 바탕화면 알림을 정한다(사용자가 보고 있는 탭이 있는지 다른 탭에 묻는다)
|
||||
// - 사용자가 어느 웹메일 탭이든 보게 되면(보이고 초점이 있음) 연결한 탭에서 onUserReturn — 띄운 바탕화면 알림을 닫는다
|
||||
//
|
||||
// 로그아웃 · 세션 만료 · 다른 계정 로그인(authStore.generation) 때 잠금을 놓고 연결을 닫는다.
|
||||
|
||||
export interface NewMailContext {
|
||||
/** 사용자가 이 탭을 보고 있는지 — 보이고(visibilityState) 초점이 있을 때(hasFocus) */
|
||||
isUserViewing: () => boolean
|
||||
/** 다른 탭 가운데 사용자가 보고 있는 탭이 있는지 — 그 탭의 답을 잠깐(MAIL_EVENTS_VISIBILITY_WAIT_MS) 기다린다 */
|
||||
askOtherTabsViewing: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export type EventStreamFetch = (input: string, init: RequestInit) => Promise<Response>
|
||||
|
||||
export interface MailRealtimeOptions {
|
||||
/** 연결한 탭이 새 메일을 받았을 때 — 바탕화면 알림을 정한다 (useNewMailNotifier). 결과(Promise 포함)는 기다리지 않는다 */
|
||||
onNewMail?: (signal: NewMailSignal, context: NewMailContext) => unknown
|
||||
/** 사용자가 웹메일 탭(어느 탭이든)을 보게 됐다 — 연결한 탭에서 부른다 (띄운 바탕화면 알림을 닫는다) */
|
||||
onUserReturn?: () => void
|
||||
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
|
||||
clock?: Clock
|
||||
/** 다시 잇는 간격의 흔들림 (0~1) — 테스트는 고정값을 넘긴다 */
|
||||
random?: () => number
|
||||
/** 테스트는 가짜 서버를 넘긴다 (test-utils/event-stream.ts) */
|
||||
fetch?: EventStreamFetch
|
||||
/** 사용자가 이 탭을 보고 있는지 — 기본은 isUserViewingPage */
|
||||
isUserViewing?: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 연결의 끝 — 401 · 실패 · 서버가 알린 끝(end).
|
||||
* established: 자리 잡았다(첫 state 뒤 정상 ping 을 받았거나 MAIL_EVENTS_STABLE_MS 동안 이어졌다) — 다시 잇는 간격을 처음부터 센다
|
||||
*/
|
||||
type StreamOutcome =
|
||||
| { kind: 'unauthorized' }
|
||||
| { kind: 'failed'; established: boolean }
|
||||
| { kind: 'ended'; established: boolean; reason: string }
|
||||
|
||||
/** 세션 갱신의 결과 — 이어서 잇는다 · 세션이 끝났다 · 장애(기다렸다 다시) */
|
||||
type RenewOutcome = 'renewed' | 'ended' | 'failed'
|
||||
|
||||
interface RealtimeSession {
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
type SignalMessage = Extract<RealtimeMessage, { type: 'ping' | 'state' | 'down' }>
|
||||
|
||||
const defaultFetch: EventStreamFetch = (input, init) => fetch(input, init)
|
||||
|
||||
/**
|
||||
* 사용자가 이 탭을 보고 있는지 — 보이고(visibilityState) 초점이 있을 때(hasFocus).
|
||||
* 두 번째 모니터에 웹메일을 띄워 두고 다른 창에서 일하면 "보고 있지 않음"이다 — 그때도 바탕화면 알림이 온다
|
||||
*/
|
||||
export function isUserViewingPage(): boolean {
|
||||
if (typeof document === 'undefined') return false
|
||||
return document.visibilityState === 'visible' && document.hasFocus()
|
||||
}
|
||||
|
||||
/** 서버가 돌려준 응답이 SSE 인지 — 프록시가 index.html(200)을 돌려준 경우 등을 거른다 */
|
||||
function isEventStream(response: Response): boolean {
|
||||
return (response.headers.get('Content-Type') ?? '').toLowerCase().startsWith(MAIL_EVENTS_CONTENT_TYPE)
|
||||
}
|
||||
|
||||
/** 읽지 않을 응답 본문을 닫는다 — 연결을 붙잡지 않게 */
|
||||
function discardBody(response: Response): void {
|
||||
void response.body?.cancel().catch(() => undefined)
|
||||
}
|
||||
|
||||
export function useMailRealtime(options: MailRealtimeOptions = {}): void {
|
||||
const authStore = useAuthStore()
|
||||
const realtimeStore = useMailRealtimeStore()
|
||||
const clock = options.clock ?? systemClock
|
||||
const random = options.random ?? Math.random
|
||||
const fetchStream = options.fetch ?? defaultFetch
|
||||
const isUserViewing = options.isUserViewing ?? isUserViewingPage
|
||||
|
||||
let session: RealtimeSession | null = null
|
||||
|
||||
function startSession(email: string, generation: number): RealtimeSession {
|
||||
const channel = new BroadcastChannel(MAIL_EVENTS_CHANNEL_NAME)
|
||||
const lockRequest = new AbortController()
|
||||
/** 탭마다 다른 앞머리 — 보고 있는 탭 묻기의 답을 가려낸다 */
|
||||
const queryPrefix = random().toString(36).slice(2)
|
||||
const pendingQueries = new Map<string, (viewing: boolean) => void>()
|
||||
let querySeq = 0
|
||||
let stopped = false
|
||||
let releaseLock: (() => void) | null = null
|
||||
let leader: { stop: () => void; replyHello: () => void } | null = null
|
||||
|
||||
// ── 연결 상태 (모든 탭 같은 규칙) ─────────────────────
|
||||
let lastSignalAt: number | null = null
|
||||
let upstreamOk = true
|
||||
let everDown = false
|
||||
let silenceTimer: ClockTimer | null = null
|
||||
|
||||
function publishStatus() {
|
||||
let next: MailRealtimeStatus
|
||||
if (lastSignalAt === null) next = everDown ? 'down' : 'connecting'
|
||||
else next = upstreamOk ? 'live' : 'down'
|
||||
realtimeStore.setStatus(next)
|
||||
}
|
||||
|
||||
/** 75초 안에 다음 신호가 없으면 끊긴 것으로 본다 (처음 잇는 중이어도 같다) */
|
||||
function armSilence() {
|
||||
if (silenceTimer !== null) clock.clearTimeout(silenceTimer)
|
||||
silenceTimer = clock.setTimeout(() => {
|
||||
silenceTimer = null
|
||||
lastSignalAt = null
|
||||
everDown = true
|
||||
publishStatus()
|
||||
}, MAIL_EVENTS_SILENCE_MS)
|
||||
}
|
||||
|
||||
/** ping · state · 끊김을 이 탭에 적는다 — 연결한 탭은 자기 소식, 다른 탭은 채널로 받은 소식 */
|
||||
function applySignal(message: SignalMessage) {
|
||||
if (message.type === 'down') {
|
||||
lastSignalAt = null
|
||||
everDown = true
|
||||
if (silenceTimer !== null) clock.clearTimeout(silenceTimer)
|
||||
silenceTimer = null
|
||||
publishStatus()
|
||||
return
|
||||
}
|
||||
lastSignalAt = clock.now()
|
||||
// state 는 서버가 엔진의 바뀜을 받았다는 뜻이다 — 서버 · 엔진 사이가 이어져 있다
|
||||
upstreamOk = message.type === 'state' ? true : message.upstream
|
||||
armSilence()
|
||||
publishStatus()
|
||||
if (message.type === 'state') realtimeStore.observeState(message.state, message.inboxUnread)
|
||||
}
|
||||
|
||||
function post(message: RealtimeMessage) {
|
||||
if (stopped) return
|
||||
try {
|
||||
channel.postMessage(packRealtimeMessage(email, message))
|
||||
} catch {
|
||||
// 닫힌 채널 — 나누지 못해도 이 탭은 그대로 간다
|
||||
}
|
||||
}
|
||||
|
||||
/** 연결한 탭의 소식 — 이 탭에 적고 다른 탭에 나눈다 */
|
||||
function share(message: SignalMessage) {
|
||||
applySignal(message)
|
||||
post(message)
|
||||
}
|
||||
|
||||
// ── 사용자가 보고 있는 탭 묻기 (바탕화면 알림 전에) ─────────────
|
||||
function askOtherTabsViewing(): Promise<boolean> {
|
||||
if (stopped) return Promise.resolve(false)
|
||||
querySeq += 1
|
||||
const id = `${queryPrefix}-${querySeq}`
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let timer: ClockTimer | null = null
|
||||
const settle = (viewing: boolean) => {
|
||||
if (!pendingQueries.delete(id)) return
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
resolve(viewing)
|
||||
}
|
||||
pendingQueries.set(id, settle)
|
||||
timer = clock.setTimeout(() => settle(false), MAIL_EVENTS_VISIBILITY_WAIT_MS)
|
||||
post({ type: 'visibility-query', id })
|
||||
})
|
||||
}
|
||||
|
||||
const newMailContext: NewMailContext = { isUserViewing, askOtherTabsViewing }
|
||||
|
||||
/** 사용자가 이 탭을 보게 됐다 — 연결한 탭이면 바로, 아니면 연결한 탭에 알린다 (띄운 바탕화면 알림을 닫는다) */
|
||||
function onUserMaybeReturned() {
|
||||
if (stopped || !isUserViewing()) return
|
||||
if (leader) options.onUserReturn?.()
|
||||
else post({ type: 'attended' })
|
||||
}
|
||||
|
||||
/** 연결한 탭이 닫힌다 — 다른 탭이 1분 확인으로 돌아가게 (잠금은 브라우저가 풀고, 다른 탭이 이어받는다) */
|
||||
function onPageHide() {
|
||||
if (leader) post({ type: 'down' })
|
||||
}
|
||||
|
||||
function onChannelMessage(event: MessageEvent<unknown>) {
|
||||
if (stopped) return
|
||||
const message = unpackRealtimeMessage(event.data, email)
|
||||
if (!message) return
|
||||
switch (message.type) {
|
||||
case 'ping':
|
||||
case 'state':
|
||||
case 'down':
|
||||
// 연결한 탭은 자기 연결의 소식만 믿는다 (잠금을 막 넘긴 탭의 늦은 끊김 등은 버린다)
|
||||
if (!leader) applySignal(message)
|
||||
break
|
||||
case 'baseline':
|
||||
// 살아 있다는 신호가 아니다 — 기준값으로만 쓴다
|
||||
if (!leader) realtimeStore.observeState(message.state, message.inboxUnread)
|
||||
break
|
||||
case 'hello':
|
||||
leader?.replyHello()
|
||||
break
|
||||
case 'visibility-query':
|
||||
if (isUserViewing()) post({ type: 'visibility-reply', id: message.id })
|
||||
break
|
||||
case 'visibility-reply':
|
||||
pendingQueries.get(message.id)?.(true)
|
||||
break
|
||||
case 'attended':
|
||||
if (leader) options.onUserReturn?.()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── 연결한 탭 ────────────────────────────────────
|
||||
function startLeader(): { stop: () => void; replyHello: () => void } {
|
||||
let leaderStopped = false
|
||||
let abortStream: (() => void) | null = null
|
||||
let wake: (() => void) | null = null
|
||||
let lastState: { state: string; inboxUnread: number | null } | null = null
|
||||
/** 자리 잡은 연결 뒤로 이어서 실패한 횟수 — 다시 잇는 간격을 정한다 */
|
||||
let attempt = 0
|
||||
|
||||
const active = () => !leaderStopped && !stopped
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = clock.setTimeout(() => {
|
||||
wake = null
|
||||
resolve()
|
||||
}, ms)
|
||||
wake = () => {
|
||||
clock.clearTimeout(timer)
|
||||
wake = null
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 세션 만료 흐름 — 갱신 요청의 401 은 인터셉터가 이미 만료로 다뤘다. 여기서는 보장만 한다 (confirmSession 과 같다) */
|
||||
function expire() {
|
||||
if (authStore.generation === generation) authStore.expireSession()
|
||||
}
|
||||
|
||||
async function renew(): Promise<RenewOutcome> {
|
||||
const outcome = await refreshSession()
|
||||
if (outcome.ok) return 'renewed'
|
||||
if (isSessionEnd(outcome.error.code)) {
|
||||
expire()
|
||||
return 'ended'
|
||||
}
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
function handleServerEvent(event: Exclude<MailServerEvent, { type: 'end' }>) {
|
||||
switch (event.type) {
|
||||
case 'state':
|
||||
lastState = { state: event.state, inboxUnread: event.inboxUnread }
|
||||
share({ type: 'state', state: event.state, inboxUnread: event.inboxUnread })
|
||||
break
|
||||
case 'ping':
|
||||
share({ type: 'ping', upstream: event.upstream })
|
||||
break
|
||||
case 'new-mail':
|
||||
// 알림을 정하는 사이에도 계속 읽는다 (다른 탭의 답을 잠깐 기다린다) — 실패해도 연결과는 상관없다
|
||||
void Promise.resolve()
|
||||
.then(() => options.onNewMail?.({ count: event.count, messages: event.messages }, newMailContext))
|
||||
.catch(() => undefined)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function readStream(): Promise<StreamOutcome> {
|
||||
const request = new AbortController()
|
||||
abortStream = () => request.abort()
|
||||
let watchdog: ClockTimer | null = null
|
||||
/** 첫 state 를 받은 때 */
|
||||
let openedAt: number | null = null
|
||||
/** 정상 ping(upstream: true)을 받았다 — 연결 직후의 upstream: false 알림은 세지 않는다 */
|
||||
let sawHealthyPing = false
|
||||
const established = () =>
|
||||
sawHealthyPing || (openedAt !== null && clock.now() - openedAt >= MAIL_EVENTS_STABLE_MS)
|
||||
// 머리글을 기다리는 동안 · 읽는 동안 모두 75초 안에 무엇이든 와야 한다 — 아니면 끊고 다시 잇는다
|
||||
const armWatchdog = () => {
|
||||
if (watchdog !== null) clock.clearTimeout(watchdog)
|
||||
watchdog = clock.setTimeout(() => request.abort(), MAIL_EVENTS_SILENCE_MS)
|
||||
}
|
||||
try {
|
||||
armWatchdog()
|
||||
const response = await fetchStream(buildApiUrl(MAIL_EVENTS_PATH), {
|
||||
method: 'GET',
|
||||
headers: { Accept: MAIL_EVENTS_CONTENT_TYPE },
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
signal: request.signal,
|
||||
})
|
||||
if (response.status === 401) {
|
||||
discardBody(response)
|
||||
return { kind: 'unauthorized' }
|
||||
}
|
||||
if (!response.ok || !response.body || !isEventStream(response)) {
|
||||
discardBody(response)
|
||||
return { kind: 'failed', established: false }
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const parser = createSseParser()
|
||||
for (;;) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done || !active()) break
|
||||
for (const raw of parser.push(decoder.decode(chunk.value, { stream: true }))) {
|
||||
armWatchdog()
|
||||
const event = parseMailEvent(raw)
|
||||
if (!event) continue
|
||||
if (event.type === 'end') return { kind: 'ended', established: established(), reason: event.reason }
|
||||
if (event.type === 'state' && openedAt === null) openedAt = clock.now()
|
||||
if (event.type === 'ping' && event.upstream) sawHealthyPing = true
|
||||
handleServerEvent(event)
|
||||
if (!active()) break
|
||||
}
|
||||
}
|
||||
return { kind: 'failed', established: established() }
|
||||
} catch {
|
||||
// 네트워크 · 읽기 오류(서버가 중간에 죽음) · 너무 긴 줄 · 그만둠(75초 · 로그아웃) — 아래 run 이 가른다
|
||||
return { kind: 'failed', established: established() }
|
||||
} finally {
|
||||
if (watchdog !== null) clock.clearTimeout(watchdog)
|
||||
request.abort()
|
||||
abortStream = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 401 · token-expiring 뒤 — 세션을 갱신한다. 이어서 잇을지(now) · 그만둘지(stop) · 기다렸다 잇을지(backoff) */
|
||||
async function renewThen(): Promise<'now' | 'stop' | 'backoff'> {
|
||||
const renewed = await renew()
|
||||
if (!active() || renewed === 'ended') return 'stop'
|
||||
return renewed === 'renewed' ? 'now' : 'backoff'
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
/** 이번 시도가 세션을 갱신한 바로 뒤의 다시 잇기다 — 이번에도 401 이면 세션 만료. 다른 까닭으로 끝나면 지운다 */
|
||||
let retryingAfterRenew = false
|
||||
while (active()) {
|
||||
const outcome = await readStream()
|
||||
if (!active()) return
|
||||
const afterRenew = retryingAfterRenew
|
||||
retryingAfterRenew = false
|
||||
if (outcome.kind !== 'unauthorized' && outcome.established) attempt = 0
|
||||
|
||||
let next: 'now' | 'spread' | 'backoff' | 'stop' = 'backoff'
|
||||
if (outcome.kind === 'unauthorized') {
|
||||
if (afterRenew) {
|
||||
share({ type: 'down' })
|
||||
expire()
|
||||
return
|
||||
}
|
||||
next = await renewThen()
|
||||
} else if (outcome.kind === 'ended' && outcome.established) {
|
||||
next = outcome.reason === MAIL_EVENTS_END_TOKEN_EXPIRING ? await renewThen() : 'spread'
|
||||
}
|
||||
|
||||
if (next === 'stop') return
|
||||
if (next === 'now') {
|
||||
retryingAfterRenew = true
|
||||
continue
|
||||
}
|
||||
if (next === 'spread') {
|
||||
// 서버가 곧 닫는다고 알렸다(종료 · 재시작) — 끊김으로 알리지 않고, 모든 브라우저가 같은 순간에 붙지 않게 조금 흔들어 다시 잇는다
|
||||
await sleep(Math.round(Math.min(1, Math.max(0, random())) * MAIL_EVENTS_SHUTDOWN_SPREAD_MS))
|
||||
continue
|
||||
}
|
||||
share({ type: 'down' })
|
||||
await sleep(reconnectDelayMs(attempt, random()))
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
leaderStopped = true
|
||||
abortStream?.()
|
||||
wake?.()
|
||||
},
|
||||
/**
|
||||
* 새로 연 탭에게 지금 상태를 다시 보낸다 — 살아 있으면 마지막 state(신호), 끊긴 채면 기준값(baseline)과 끊김만
|
||||
* (끊긴 채 state 를 보내면 다른 탭이 잠깐 live 가 되었다가 down 으로 돌아가며 1분 확인을 한 번 더 한다)
|
||||
*/
|
||||
replyHello: () => {
|
||||
const status = realtimeStore.status
|
||||
if (status === 'live') {
|
||||
post(lastState ? { type: 'state', ...lastState } : { type: 'ping', upstream: true })
|
||||
return
|
||||
}
|
||||
if (lastState) post({ type: 'baseline', ...lastState })
|
||||
if (status === 'down') post({ type: 'down' })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (stopped) return
|
||||
// 연결을 맡던 탭이면 다른 탭이 1분 확인으로 돌아가게 알리고 놓는다 (다른 탭이 잠금을 받아 이어서 연결한다)
|
||||
if (leader) post({ type: 'down' })
|
||||
stopped = true
|
||||
lockRequest.abort()
|
||||
leader?.stop()
|
||||
leader = null
|
||||
releaseLock?.()
|
||||
releaseLock = null
|
||||
if (silenceTimer !== null) clock.clearTimeout(silenceTimer)
|
||||
silenceTimer = null
|
||||
pendingQueries.forEach((settle) => settle(false))
|
||||
channel.removeEventListener('message', onChannelMessage)
|
||||
channel.close()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('pagehide', onPageHide)
|
||||
window.removeEventListener('focus', onUserMaybeReturned)
|
||||
}
|
||||
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onUserMaybeReturned)
|
||||
}
|
||||
|
||||
/** 잠금을 쓸 수 없는 환경(보안 제한 등) — 실시간 없이 1분 확인만 한다 */
|
||||
function giveUp() {
|
||||
stop()
|
||||
realtimeStore.setStatus('unsupported')
|
||||
}
|
||||
|
||||
channel.addEventListener('message', onChannelMessage)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pagehide', onPageHide)
|
||||
window.addEventListener('focus', onUserMaybeReturned)
|
||||
}
|
||||
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onUserMaybeReturned)
|
||||
publishStatus()
|
||||
armSilence()
|
||||
post({ type: 'hello' })
|
||||
|
||||
try {
|
||||
navigator.locks
|
||||
.request(mailEventsLockName(email), { signal: lockRequest.signal }, () =>
|
||||
new Promise<void>((resolve) => {
|
||||
// 잠금을 받은 동안 이 탭이 연결한다 — 돌려준 Promise 가 끝나면(stop) 잠금이 풀린다
|
||||
if (stopped) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
releaseLock = resolve
|
||||
// 이제 이 탭의 연결이 기준이다 — 앞 탭의 소식으로 정한 상태는 버리고, 이 연결이 열릴 때까지 "잇는 중".
|
||||
// 다른 탭에는 끊김을 알린다 — 앞 탭이 stop 없이 닫혔으면(탭 닫기 · 새로 고침 · 폐기) 다른 탭은 아직 live 로 알고 있다
|
||||
lastSignalAt = null
|
||||
everDown = false
|
||||
armSilence()
|
||||
publishStatus()
|
||||
post({ type: 'down' })
|
||||
leader = startLeader()
|
||||
}),
|
||||
)
|
||||
.catch(() => {
|
||||
// 기다리다 그만둔 요청(AbortError)이면 할 일이 없다. 그 밖의 거절은 잠금을 쓸 수 없는 것이다
|
||||
if (!stopped) giveUp()
|
||||
})
|
||||
} catch {
|
||||
giveUp()
|
||||
}
|
||||
|
||||
return { stop }
|
||||
}
|
||||
|
||||
function restart() {
|
||||
session?.stop()
|
||||
session = null
|
||||
const email = authStore.user?.email
|
||||
if (!email) {
|
||||
realtimeStore.setStatus('off')
|
||||
return
|
||||
}
|
||||
if (!supportsMailRealtime()) {
|
||||
realtimeStore.setStatus('unsupported')
|
||||
return
|
||||
}
|
||||
try {
|
||||
session = startSession(email, authStore.generation)
|
||||
} catch {
|
||||
// 채널을 만들지 못했다 — 실시간 없이 1분 확인만 한다
|
||||
realtimeStore.setStatus('unsupported')
|
||||
}
|
||||
}
|
||||
|
||||
// 로그인 · 세션 복원으로 시작하고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 그만둔다(새 세션이면 다시 시작).
|
||||
// 토큰 갱신(같은 세션)으로는 다시 시작하지 않는다 — 세대와 주소가 그대로다 (값마다 비교하도록 배열 소스로 둔다)
|
||||
watch([() => authStore.generation, () => authStore.user?.email ?? null], restart, { immediate: true })
|
||||
|
||||
onScopeDispose(() => {
|
||||
session?.stop()
|
||||
session = null
|
||||
realtimeStore.setStatus('off')
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,9 @@ 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 { MAIL_REALTIME_SYNC_DELAY_MS } from '@/shared/constants/mail-notify'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
|
||||
// 새 메일 확인 (계약 2절 "새 메일 확인") — 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
|
||||
// 새 메일 확인 (계약 2절 "새 메일 확인" · "실시간 알림") — 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
|
||||
|
||||
describe('useMailStatePolling (새 메일 확인)', () => {
|
||||
let scope: EffectScope
|
||||
@@ -26,6 +28,7 @@ describe('useMailStatePolling (새 메일 확인)', () => {
|
||||
let states: MockResult[]
|
||||
let requests: MockRequest[]
|
||||
const onChange = vi.fn()
|
||||
const onHiddenChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -35,6 +38,7 @@ describe('useMailStatePolling (새 메일 확인)', () => {
|
||||
visibility = 'visible'
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
|
||||
onChange.mockReset()
|
||||
onHiddenChange.mockReset()
|
||||
states = []
|
||||
requests = installMockApi(() => states.shift() ?? ok({ state: 'same' }))
|
||||
})
|
||||
@@ -47,7 +51,7 @@ describe('useMailStatePolling (새 메일 확인)', () => {
|
||||
})
|
||||
|
||||
function start() {
|
||||
scope.run(() => useMailStatePolling({ onChange, clock }))
|
||||
scope.run(() => useMailStatePolling({ onChange, onHiddenChange, clock }))
|
||||
}
|
||||
|
||||
function setVisibility(next: DocumentVisibilityState) {
|
||||
@@ -169,4 +173,151 @@ describe('useMailStatePolling (새 메일 확인)', () => {
|
||||
expect(asked()).toBe(1)
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
describe('실시간 연결과 함께 (계약 2절 "실시간 알림")', () => {
|
||||
it('실시간 연결이 살아 있는 동안은 묻지 않는다 — 끊기면 바로 한 번 묻고 다시 1분마다, 살아나면 멈춘다', async () => {
|
||||
const realtime = useMailRealtimeStore()
|
||||
realtime.setStatus('live')
|
||||
start()
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 3)
|
||||
expect(asked()).toBe(0)
|
||||
|
||||
realtime.setStatus('down')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(asked()).toBe(2)
|
||||
|
||||
realtime.setStatus('live')
|
||||
await flushPromises()
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 3)
|
||||
expect(asked()).toBe(2)
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('살아 있는 동안 탭으로 돌아와도 묻지 않는다 (10초 규칙은 1분 확인을 할 때만)', async () => {
|
||||
useMailRealtimeStore().setStatus('live')
|
||||
start()
|
||||
setVisibility('hidden')
|
||||
await tick(MAIL_STATE_RECHECK_GAP_MS * 6)
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
|
||||
expect(asked()).toBe(0)
|
||||
})
|
||||
|
||||
it('실시간 소식으로 바뀌면(보이는 탭) 1초 모았다가 맞춘다 — 1분 확인과 같은 기준이라 같은 값은 다시 맞추지 않는다', async () => {
|
||||
const realtime = useMailRealtimeStore()
|
||||
realtime.setStatus('live')
|
||||
start()
|
||||
realtime.observeState('s1', 0)
|
||||
await flushPromises()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
realtime.observeState('s2', 1)
|
||||
await flushPromises()
|
||||
await tick(MAIL_REALTIME_SYNC_DELAY_MS - 1)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
await tick(1)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onHiddenChange).not.toHaveBeenCalled()
|
||||
|
||||
// 연결이 끊긴 뒤의 1분 확인이 같은 값을 받으면 바뀐 것이 아니다
|
||||
states = [ok({ state: 's2' })]
|
||||
realtime.setStatus('down')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('모으는 사이 온 바뀜은 함께 한 번에 맞춘다 (내가 여러 통을 읽어 state 가 이어서 바뀌어도)', async () => {
|
||||
const realtime = useMailRealtimeStore()
|
||||
realtime.setStatus('live')
|
||||
start()
|
||||
realtime.observeState('s1', 0)
|
||||
|
||||
// 첫 바뀜부터 1초 — 그사이 바뀜은 함께 (다시 세지 않는다)
|
||||
realtime.observeState('s2', 1)
|
||||
await flushPromises()
|
||||
await tick(300)
|
||||
realtime.observeState('s3', 0)
|
||||
await flushPromises()
|
||||
await tick(300)
|
||||
realtime.observeState('s4', 1)
|
||||
await flushPromises()
|
||||
await tick(MAIL_REALTIME_SYNC_DELAY_MS - 600 - 1)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
await tick(1)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
await tick(MAIL_REALTIME_SYNC_DELAY_MS * 3)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('모으는 동안 숨으면 맞추지 않고 다시 보일 때 바로 맞춘다', async () => {
|
||||
const realtime = useMailRealtimeStore()
|
||||
realtime.setStatus('live')
|
||||
start()
|
||||
realtime.observeState('s1', 0)
|
||||
realtime.observeState('s2', 1)
|
||||
await flushPromises()
|
||||
setVisibility('hidden')
|
||||
|
||||
await tick(MAIL_REALTIME_SYNC_DELAY_MS)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('숨은 탭 — 받은편지함의 안 읽은 수만 넘기고(탭 제목), 다시 보일 때 한 번 맞춘다', async () => {
|
||||
const realtime = useMailRealtimeStore()
|
||||
realtime.setStatus('live')
|
||||
start()
|
||||
realtime.observeState('s1', 2)
|
||||
setVisibility('hidden')
|
||||
|
||||
realtime.observeState('s2', 3)
|
||||
await flushPromises()
|
||||
realtime.observeState('s3', 4)
|
||||
await flushPromises()
|
||||
expect(onHiddenChange.mock.calls).toEqual([[3], [4]])
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
setVisibility('hidden')
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('메일 화면 밖(설정 등)에 있는 동안 생긴 바뀜은 메일 화면이 생길 때 맞춘다', async () => {
|
||||
const realtime = useMailRealtimeStore()
|
||||
realtime.setStatus('live')
|
||||
realtime.observeState('s1', 0)
|
||||
realtime.observeState('s2', 1)
|
||||
|
||||
start()
|
||||
await flushPromises()
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(asked()).toBe(0)
|
||||
})
|
||||
|
||||
it('1분 확인이 본 바뀜도 같은 길로 맞춘다 · 세션이 바뀌면 지난 기준을 버린다', async () => {
|
||||
states = [ok({ state: 's1' }), ok({ state: 's2' }), ok({ state: 'other-user' })]
|
||||
start()
|
||||
await flushPromises()
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(3)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,19 +3,32 @@ 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 { MAIL_REALTIME_SYNC_DELAY_MS } from '@/shared/constants/mail-notify'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
|
||||
|
||||
// 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일")
|
||||
// - 로그인한 동안, 화면이 보이는 동안(visibilityState === 'visible')만 60초마다 GET /mail-state 를 묻는다
|
||||
// - 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번 (안 지났으면 원래 차례에)
|
||||
// - 한 번에 하나씩 — 묻는 중이면 새로 묻지 않는다
|
||||
// - 처음 받은 값은 기준으로만 쓰고, 값이 바뀌면 onChange 를 부른다 (메일함 개수 · 보이는 목록 맞추기는 부르는 쪽이)
|
||||
// - 실패(MAIL_001 · 네트워크)는 알리지 않고(silent) 다음 차례에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 탄다
|
||||
// 실시간 푸시가 아니라 1분 확인이다 — 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 (계약 같은 절)
|
||||
// 메일 화면이 새 메일 · 다른 창의 변화를 알아채고 맞춘다
|
||||
// (계약 2절 "새 메일 확인" · "실시간 알림" · 디자인 README "3단계 › 새 메일" · "5단계 › 실시간 반영")
|
||||
// 메일 화면(MailLayout)이 있는 동안만 돈다. 바뀜은 두 곳에서 오고, 둘 다 같은 기준(마지막으로 본 state — mail-realtime.store)으로 비교한다:
|
||||
// 1) 실시간 소식 — 로그인한 동안 앱 전체에서 도는 연결(useMailRealtime)의 state. 이 탭이 연결했든 다른 탭이 연결했든 같다
|
||||
// 2) 1분 확인(GET /mail-state) — 실시간 연결이 살아 있지 않을 때만
|
||||
// - 로그인한 동안, 화면이 보이는 동안(visibilityState === 'visible')만 60초마다
|
||||
// - 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번 (안 지났으면 원래 차례에)
|
||||
// - 실시간 연결이 끊기면 바로 한 번 묻고 다시 60초마다 — 살아나면 멈춘다
|
||||
// - 한 번에 하나씩. 실패(MAIL_001 · 네트워크)는 알리지 않고(silent) 다음 차례에. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)
|
||||
// 처음 본 값은 기준으로만 쓴다.
|
||||
// 바뀌었으면:
|
||||
// - 화면이 보이면 onChange (메일함 개수 · 보이는 목록 맞추기 — 부르는 쪽이). 실시간 소식이 알린 바뀜은 첫 바뀜부터 1초 모았다가
|
||||
// 한 번에 (MAIL_REALTIME_SYNC_DELAY_MS — 내가 한 통만 읽어도 state 가 바뀌어, 바뀔 때마다 탭마다 다시 받으면 요청이 많다).
|
||||
// 1분 확인이 본 바뀜은 바로
|
||||
// - 숨었으면 onHiddenChange(받은편지함의 안 읽은 수 — 탭 제목 "(N) " 만 맞춘다) 하고, 다시 보일 때 onChange (바로)
|
||||
// - 메일 화면 밖(설정 등)에 있는 동안 생긴 바뀜은 메일 화면으로 돌아오면 onChange (바로)
|
||||
|
||||
export interface MailStatePollingOptions {
|
||||
/** 지난 값과 달라졌을 때 */
|
||||
/** 바뀌었고 화면이 보일 때 */
|
||||
onChange: () => void
|
||||
/** 숨은 탭에서 실시간 소식으로 바뀌었을 때 — 받은편지함의 안 읽은 수 */
|
||||
onHiddenChange?: (inboxUnread: number) => void
|
||||
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
|
||||
clock?: Clock
|
||||
}
|
||||
@@ -32,15 +45,21 @@ function isPageVisible(): boolean {
|
||||
export function useMailStatePolling(options: MailStatePollingOptions): MailStatePollingHandle {
|
||||
const { getMailState } = useMail()
|
||||
const authStore = useAuthStore()
|
||||
const realtimeStore = useMailRealtimeStore()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
/** 지난 값 — 처음 받은 값은 기준으로만 쓴다 */
|
||||
let baseline: string | null = null
|
||||
let lastAskedAt: number | null = null
|
||||
let timer: ClockTimer | null = null
|
||||
/** 실시간 소식이 알린 바뀜을 모으는 중 — 끝나면 한 번에 맞춘다 */
|
||||
let syncTimer: ClockTimer | null = null
|
||||
let inFlight = false
|
||||
let stopped = false
|
||||
|
||||
/** 1분 확인을 할 때인지 — 실시간 연결이 살아 있으면 묻지 않는다 */
|
||||
function shouldPoll(): boolean {
|
||||
return !stopped && isPageVisible() && authStore.isLoggedIn && !realtimeStore.isLive
|
||||
}
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
timer = null
|
||||
@@ -48,7 +67,7 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
|
||||
|
||||
function schedule(delay: number) {
|
||||
cancelTimer()
|
||||
if (stopped || !isPageVisible() || !authStore.isLoggedIn) return
|
||||
if (!shouldPoll()) return
|
||||
timer = clock.setTimeout(
|
||||
() => {
|
||||
timer = null
|
||||
@@ -59,7 +78,7 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
|
||||
}
|
||||
|
||||
async function ask(): Promise<void> {
|
||||
if (stopped || inFlight || !isPageVisible() || !authStore.isLoggedIn) return
|
||||
if (inFlight || !shouldPoll()) return
|
||||
inFlight = true
|
||||
lastAskedAt = clock.now()
|
||||
const session = authStore.generation
|
||||
@@ -67,9 +86,8 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
|
||||
const { state } = await getMailState({ silent: true })
|
||||
// 묻는 사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면 버린다
|
||||
if (stopped || authStore.generation !== session) return
|
||||
const changed = baseline !== null && state !== baseline
|
||||
baseline = state
|
||||
if (changed) options.onChange()
|
||||
// 1분 확인이 본 바뀜은 기다리지 않고 맞춘다 (이미 최대 1분 늦었다)
|
||||
if (realtimeStore.observeState(state)) syncPending()
|
||||
} catch {
|
||||
// 알리지 않는다 — 다음 차례에 다시 묻는다
|
||||
} finally {
|
||||
@@ -78,13 +96,35 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSyncTimer() {
|
||||
if (syncTimer !== null) clock.clearTimeout(syncTimer)
|
||||
syncTimer = null
|
||||
}
|
||||
|
||||
/** 남은 바뀜을 맞춘다 — 화면이 보일 때만 (숨었으면 다시 보일 때) */
|
||||
function syncPending() {
|
||||
if (stopped || !isPageVisible()) return
|
||||
cancelSyncTimer()
|
||||
if (realtimeStore.takePendingSync()) options.onChange()
|
||||
}
|
||||
|
||||
/** 실시간 소식이 알린 바뀜 — 첫 바뀜부터 잠깐 모았다가 한 번에 맞춘다 (모으는 사이 온 바뀜은 함께) */
|
||||
function scheduleSync() {
|
||||
if (syncTimer !== null) return
|
||||
syncTimer = clock.setTimeout(() => {
|
||||
syncTimer = null
|
||||
syncPending()
|
||||
}, MAIL_REALTIME_SYNC_DELAY_MS)
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (stopped) return
|
||||
if (!isPageVisible()) {
|
||||
cancelTimer()
|
||||
return
|
||||
}
|
||||
if (inFlight) return
|
||||
syncPending()
|
||||
if (inFlight || realtimeStore.isLive) 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)
|
||||
@@ -93,21 +133,45 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
|
||||
function stop() {
|
||||
stopped = true
|
||||
cancelTimer()
|
||||
cancelSyncTimer()
|
||||
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
// 세션이 바뀌면(다시 로그인 등) 지난 값을 버리고 새로 기준을 잡는다
|
||||
// 바뀜이 생기면 — 보이면 잠깐 모았다가 맞추고(1분 확인이 본 것은 이미 맞췄다), 숨었으면 탭 제목의 안 읽은 수만 바로
|
||||
watch(
|
||||
() => realtimeStore.changeSeq,
|
||||
() => {
|
||||
if (stopped) return
|
||||
if (!isPageVisible()) {
|
||||
if (realtimeStore.inboxUnread !== null) options.onHiddenChange?.(realtimeStore.inboxUnread)
|
||||
return
|
||||
}
|
||||
if (realtimeStore.pendingSync) scheduleSync()
|
||||
},
|
||||
)
|
||||
|
||||
// 실시간 연결이 살아나면 1분 확인을 멈추고, 끊기면 바로 한 번 묻고 다시 1분마다
|
||||
watch(
|
||||
() => realtimeStore.isLive,
|
||||
(live) => {
|
||||
if (live) cancelTimer()
|
||||
else void ask()
|
||||
},
|
||||
)
|
||||
|
||||
// 세션이 바뀌면(다시 로그인 등) 새로 기준을 잡는다 — 지난 기준은 스토어가 버렸다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
baseline = null
|
||||
if (authStore.isLoggedIn) void ask()
|
||||
else cancelTimer()
|
||||
},
|
||||
)
|
||||
|
||||
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
// 바로 한 번 물어 기준을 잡는다 — 1분 뒤에 처음 물으면 그 사이에 온 메일은 2분 뒤에야 알아챈다
|
||||
// 메일 화면 밖에 있는 동안 생긴 바뀜을 맞추고, 바로 한 번 물어 기준을 잡는다
|
||||
// (1분 뒤에 처음 물으면 그 사이에 온 메일은 2분 뒤에야 알아챈다 — 실시간이 살아 있으면 묻지 않는다)
|
||||
syncPending()
|
||||
void ask()
|
||||
onScopeDispose(stop)
|
||||
|
||||
|
||||
@@ -83,6 +83,27 @@ describe('useMailTabTitle (탭 제목)', () => {
|
||||
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('숨은 탭 — 실시간 소식의 안 읽은 수(setInboxUnread)로 바로 맞춘다 (계약 2절 "실시간 알림")', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
await mountHost()
|
||||
|
||||
useMailStore().setInboxUnread(5)
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('(5) 메일 · ARA SYSTEM')
|
||||
|
||||
useMailStore().setInboxUnread(0)
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('메일함을 아직 받지 않았으면 안 읽은 수를 고칠 메일함이 없다 — 받은 뒤의 값을 쓴다', async () => {
|
||||
await mountHost()
|
||||
|
||||
useMailStore().setInboxUnread(5)
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('메일 모듈 밖에서는 붙이지 않는다', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
const router = await mountHost()
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import type { NewMailContext } from '@/composables/useMailRealtime'
|
||||
import { useNewMailNotifier } from '@/composables/useNewMailNotifier'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import type { NewMailItem, NewMailSignal } from '@/shared/utils/mail-events'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailNotifyStore } from '@/stores/mail-notify.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
import { FakeNotification } from '@/test-utils/notification'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
|
||||
|
||||
// 새 메일 바탕화면 알림 (계약 2절 "실시간 알림" · 디자인 README "5단계 › 바탕화면 알림")
|
||||
// 설정을 켰고 · 브라우저가 허락했고 · 보이는 웹메일 탭이 하나도 없을 때만 — 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 3,
|
||||
}
|
||||
|
||||
function item(id: string, name: string, email: string, subject = `제목 ${id}`): NewMailItem {
|
||||
return { id, from: { name, email }, subject, receivedAt: '2026-10-28T05:42:00.000Z' }
|
||||
}
|
||||
|
||||
const ONE: NewMailSignal = { count: 1, messages: [item('m1', '박서준', 'seojun.park@arasystem.test', '11월 납품분 견적서')] }
|
||||
const THREE: NewMailSignal = {
|
||||
count: 3,
|
||||
messages: [
|
||||
item('m3', '박서준', 'seojun.park@arasystem.test'),
|
||||
item('m2', '이도윤', 'doyun.lee@arasystem.test'),
|
||||
item('m1', '최지호', 'jiho.choi@arasystem.test'),
|
||||
],
|
||||
}
|
||||
|
||||
describe('useNewMailNotifier (새 메일 바탕화면 알림)', () => {
|
||||
let pinia: Pinia
|
||||
let router: Router
|
||||
let notify: ReturnType<typeof useNewMailNotifier>['notify']
|
||||
let dismiss: ReturnType<typeof useNewMailNotifier>['dismiss']
|
||||
let viewing: boolean
|
||||
let otherTabViewing: boolean
|
||||
let askOtherTabsViewing: Mock<() => Promise<boolean>>
|
||||
let apiRequests: MockRequest[]
|
||||
|
||||
beforeEach(async () => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
window.localStorage.clear()
|
||||
installFakeLocks()
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
vi.stubGlobal('Notification', FakeNotification)
|
||||
FakeNotification.reset('granted')
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
apiRequests = installMockApi(() =>
|
||||
ok({ items: [INBOX], pageInfo: { hasNext: false, nextCursor: null, total: 1 } }),
|
||||
)
|
||||
viewing = false
|
||||
otherTabViewing = false
|
||||
askOtherTabsViewing = vi.fn(() => Promise.resolve(otherTabViewing))
|
||||
|
||||
router = createTestRouter()
|
||||
await router.push('/settings/mail/notify')
|
||||
await router.isReady()
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const notifier = useNewMailNotifier()
|
||||
notify = notifier.notify
|
||||
dismiss = notifier.dismiss
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
mount(Host, { global: { plugins: [pinia, router] } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
FakeBroadcastChannel.closeAll()
|
||||
uninstallFakeLocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
const context = (): NewMailContext => ({ isUserViewing: () => viewing, askOtherTabsViewing })
|
||||
|
||||
async function enable() {
|
||||
await useMailNotifyStore().turnOn()
|
||||
}
|
||||
|
||||
it('설정을 켜지 않았으면 알리지 않는다 — 다른 탭에 묻지도 않는다', async () => {
|
||||
await expect(notify(ONE, context())).resolves.toBe(false)
|
||||
|
||||
expect(FakeNotification.shown).toEqual([])
|
||||
expect(askOtherTabsViewing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('브라우저가 허락하지 않았으면 알리지 않는다', async () => {
|
||||
await enable()
|
||||
FakeNotification.permission = 'denied'
|
||||
|
||||
await expect(notify(ONE, context())).resolves.toBe(false)
|
||||
expect(FakeNotification.shown).toEqual([])
|
||||
})
|
||||
|
||||
it('사용자가 이 탭을 보고 있으면(보이고 초점이 있음) 알리지 않는다 — 목록 · 탭 제목에 바로 보이므로', async () => {
|
||||
await enable()
|
||||
viewing = true
|
||||
|
||||
await expect(notify(ONE, context())).resolves.toBe(false)
|
||||
expect(askOtherTabsViewing).not.toHaveBeenCalled()
|
||||
expect(FakeNotification.shown).toEqual([])
|
||||
})
|
||||
|
||||
it('사용자가 다른 탭을 보고 있으면 알리지 않는다', async () => {
|
||||
await enable()
|
||||
otherTabViewing = true
|
||||
|
||||
await expect(notify(ONE, context())).resolves.toBe(false)
|
||||
expect(askOtherTabsViewing).toHaveBeenCalledTimes(1)
|
||||
expect(FakeNotification.shown).toEqual([])
|
||||
})
|
||||
|
||||
it('보고 있는 탭이 없으면 한 통 — 제목은 보낸 사람, 내용은 메일 제목, tag 하나로 바꿔 쓰고 다시 알린다 · ARA 아이콘', async () => {
|
||||
await enable()
|
||||
|
||||
await expect(notify(ONE, context())).resolves.toBe(true)
|
||||
|
||||
expect(FakeNotification.shown).toHaveLength(1)
|
||||
const [shown] = FakeNotification.shown
|
||||
expect(shown?.title).toBe('박서준')
|
||||
expect(shown?.options).toMatchObject({ body: '11월 납품분 견적서', tag: 'arasystem-new-mail', renotify: true })
|
||||
expect(shown?.options.icon).toMatch(/notification-icon/)
|
||||
})
|
||||
|
||||
it('여러 통 — "새 메일 3통" · "박서준 · 이도윤 외 1명"', async () => {
|
||||
await enable()
|
||||
|
||||
await notify(THREE, context())
|
||||
|
||||
expect(FakeNotification.shown.map((shown) => [shown.title, shown.options.body])).toEqual([
|
||||
['새 메일 3통', '박서준 · 이도윤 외 1명'],
|
||||
])
|
||||
})
|
||||
|
||||
it('「메일 제목도 보이기」를 끄면 한 통의 내용은 "새 메일이 왔습니다" — 여러 통은 그대로', async () => {
|
||||
await enable()
|
||||
useMailNotifyStore().setShowSubject(false)
|
||||
|
||||
await notify(ONE, context())
|
||||
await notify(THREE, context())
|
||||
|
||||
expect(FakeNotification.shown.map((shown) => [shown.title, shown.options.body])).toEqual([
|
||||
['박서준', '새 메일이 왔습니다'],
|
||||
['새 메일 3통', '박서준 · 이도윤 외 1명'],
|
||||
])
|
||||
})
|
||||
|
||||
it('누르면 창을 앞으로 가져오고 알림을 닫은 뒤 그 메일을 연다 — 기록에 쌓는다', async () => {
|
||||
await enable()
|
||||
useMailStore().mailboxes = [INBOX]
|
||||
const focus = vi.spyOn(window, 'focus').mockImplementation(() => undefined)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
await notify(ONE, context())
|
||||
|
||||
FakeNotification.shown[0]?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(focus).toHaveBeenCalledTimes(1)
|
||||
expect(FakeNotification.shown[0]?.closed).toBe(true)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
expect(push).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('여러 통이면 받은편지함을 연다', async () => {
|
||||
await enable()
|
||||
useMailStore().mailboxes = [INBOX]
|
||||
vi.spyOn(window, 'focus').mockImplementation(() => undefined)
|
||||
await notify(THREE, context())
|
||||
|
||||
FakeNotification.shown[0]?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('받은편지함을 아직 모르면(설정 화면 등) 메일함을 받아 본 뒤 연다', async () => {
|
||||
await enable()
|
||||
vi.spyOn(window, 'focus').mockImplementation(() => undefined)
|
||||
await notify(ONE, context())
|
||||
|
||||
FakeNotification.shown[0]?.click()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(apiRequests.map((request) => request.url)).toEqual(['/mailboxes'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
})
|
||||
|
||||
it('다른 탭의 답을 기다리는 사이 로그아웃하면 알리지 않는다', async () => {
|
||||
await enable()
|
||||
let answer: (value: boolean) => void = () => undefined
|
||||
askOtherTabsViewing.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
answer = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const pending = notify(ONE, context())
|
||||
useAuthStore().clearAuth()
|
||||
answer(false)
|
||||
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(FakeNotification.shown).toEqual([])
|
||||
})
|
||||
|
||||
it('답을 기다리는 사이 사용자가 돌아왔으면(보게 됨) 알리지 않는다', async () => {
|
||||
await enable()
|
||||
askOtherTabsViewing.mockImplementation(() => {
|
||||
viewing = true
|
||||
return Promise.resolve(false)
|
||||
})
|
||||
|
||||
await expect(notify(ONE, context())).resolves.toBe(false)
|
||||
expect(FakeNotification.shown).toEqual([])
|
||||
})
|
||||
|
||||
it('만들 수 없는 브라우저(휴대폰 Chrome 등 — 만들면 예외)면 알리지 못하고 설정을 "지원하지 않음"으로 바꾼다', async () => {
|
||||
await enable()
|
||||
FakeNotification.constructorThrows = true
|
||||
|
||||
await expect(notify(ONE, context())).resolves.toBe(false)
|
||||
|
||||
const notifyStore = useMailNotifyStore()
|
||||
expect(notifyStore.permission).toBe('unsupported')
|
||||
expect(notifyStore.canNotify).toBe(false)
|
||||
// 다음 새 메일은 묻지도 않는다
|
||||
await expect(notify(ONE, context())).resolves.toBe(false)
|
||||
expect(askOtherTabsViewing).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('사용자가 웹메일로 돌아오면(dismiss) 띄운 알림을 닫는다 — 띄운 것이 없으면 할 일이 없다', async () => {
|
||||
await enable()
|
||||
expect(() => dismiss()).not.toThrow()
|
||||
|
||||
await notify(ONE, context())
|
||||
expect(FakeNotification.shown[0]?.closed).toBe(false)
|
||||
dismiss()
|
||||
|
||||
expect(FakeNotification.shown[0]?.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('누르기 전에 세션이 바뀌었으면(다른 계정) 이동하지 않는다 — 메일 id 는 계정마다 겹칠 수 있다', async () => {
|
||||
await enable()
|
||||
useMailStore().mailboxes = [INBOX]
|
||||
vi.spyOn(window, 'focus').mockImplementation(() => undefined)
|
||||
await notify(ONE, context())
|
||||
|
||||
useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })
|
||||
FakeNotification.shown[0]?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/notify')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { showDesktopNotification } from './useDesktopNotification'
|
||||
import type { NewMailContext } from './useMailRealtime'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import { newMailNotificationContent, type NewMailNotificationTarget } from '@/shared/utils/desktop-notify'
|
||||
import type { NewMailSignal } from '@/shared/utils/mail-events'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailNotifyStore } from '@/stores/mail-notify.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 새 메일 바탕화면 알림 — 계약 2절 "실시간 알림" · 디자인 README "5단계 › 바탕화면 알림"
|
||||
// new-mail 을 받은 연결 탭(useMailRealtime)이 부른다. 아래가 모두 맞을 때만 알린다:
|
||||
// - 설정 › 메일 › 알림을 켰다 (이 브라우저 · 이 사용자) · 브라우저가 허락했다(Notification.permission === 'granted')
|
||||
// - 사용자가 보고 있는 웹메일 탭이 하나도 없다 — "보고 있음"은 보이고(visibilityState) 초점이 있음(hasFocus).
|
||||
// 이 탭이 그렇지 않고, 다른 탭에 물어 그런 탭의 답이 없을 때. 두 번째 모니터에 웹메일을 띄워 두고 다른 창에서 일하면 알린다
|
||||
// 알림은 하나의 tag 로 앞 알림을 바꿔 쓴다. 누르면 이 탭으로 와서 그 메일을 연다(여러 통이면 받은편지함) — 기록에 쌓는다(목록에서 여는 것과 같다).
|
||||
// 사용자가 어느 웹메일 탭이든 보게 되면 띄운 알림을 닫는다 (dismiss — useMailRealtime 의 onUserReturn).
|
||||
// 만들 수 없는 브라우저(휴대폰 Chrome 등)로 드러나면 설정을 "지원하지 않음"으로 바꾼다 (mail-notify.store 의 markUnsupported).
|
||||
// 묻는 사이 · 누르기 전에 세션이 바뀌었으면(로그아웃 · 다른 계정) 아무것도 하지 않는다 — 메일 id 는 계정마다 겹칠 수 있다.
|
||||
// App.vue 의 setup 에서 부른다 (라우터를 쓴다).
|
||||
|
||||
export function useNewMailNotifier() {
|
||||
const authStore = useAuthStore()
|
||||
const notifyStore = useMailNotifyStore()
|
||||
const mailStore = useMailStore()
|
||||
const { openFromNotification } = useMailNavigation()
|
||||
|
||||
/** 마지막으로 띄운 알림을 닫는다 — 띄운 것이 없으면 null */
|
||||
let closeShown: (() => void) | null = null
|
||||
|
||||
/** 알림을 눌렀다 — 받은편지함을 아직 모르면 받아 본 뒤, 그래도 모르면 메일 첫 화면으로 */
|
||||
async function open(target: NewMailNotificationTarget, session: number): Promise<void> {
|
||||
closeShown = null
|
||||
if (authStore.generation !== session) return
|
||||
const inbox = await mailStore.resolveMailboxByRole('inbox')
|
||||
if (authStore.generation !== session) return
|
||||
await openFromNotification(
|
||||
'mailbox' in inbox ? inbox.mailbox.id : null,
|
||||
target.kind === 'message' ? target.messageId : null,
|
||||
)
|
||||
}
|
||||
|
||||
/** 알릴 수 있는지 — 설정 · 브라우저의 허락을 지금 다시 본다 */
|
||||
function canNotify(): boolean {
|
||||
notifyStore.refreshPermission()
|
||||
return notifyStore.canNotify
|
||||
}
|
||||
|
||||
/** @returns 알렸는지 */
|
||||
async function notify(signal: NewMailSignal, context: NewMailContext): Promise<boolean> {
|
||||
if (!canNotify() || context.isUserViewing()) return false
|
||||
const session = authStore.generation
|
||||
if (await context.askOtherTabsViewing()) return false
|
||||
// 답을 기다리는 사이 설정을 끄거나(다른 탭) 세션이 바뀌었거나 사용자가 돌아왔을 수 있다
|
||||
if (authStore.generation !== session || !canNotify() || context.isUserViewing()) return false
|
||||
const content = newMailNotificationContent(signal, { showSubject: notifyStore.settings.showSubject })
|
||||
if (!content) return false
|
||||
const result = showDesktopNotification({
|
||||
title: content.title,
|
||||
body: content.body,
|
||||
onClick: () => void open(content.target, session),
|
||||
})
|
||||
if (result.status === 'unsupported') notifyStore.markUnsupported()
|
||||
if (result.status !== 'shown') return false
|
||||
closeShown = result.close
|
||||
return true
|
||||
}
|
||||
|
||||
/** 사용자가 웹메일로 돌아왔다 — 띄운 알림을 닫는다 (목록 · 탭 제목에 바로 보이므로) */
|
||||
function dismiss(): void {
|
||||
closeShown?.()
|
||||
closeShown = null
|
||||
}
|
||||
|
||||
return { notify, dismiss }
|
||||
}
|
||||
@@ -39,6 +39,14 @@ export function useToast() {
|
||||
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면이 직접 띄우는 오류 알림 — API 오류가 아니라 브라우저 상태를 알릴 때만 쓴다 (예: 바탕화면 알림을 막아 둠 + [설정 열기]).
|
||||
* API 오류는 인터셉터가 사전 문구로 이미 띄운다 — 호출부에서 다시 띄우지 않는다
|
||||
*/
|
||||
function error(message: string, action?: ToastAction): number {
|
||||
return store.show({ kind: 'error', message, action: action ? sessionBound(action) : undefined })
|
||||
}
|
||||
|
||||
/** 진행 알림을 띄운다 — 저절로 닫히지 않는다. 끝나면 succeed · fail · dismiss 중 하나를 부른다 */
|
||||
function progress(message: string): ProgressToast {
|
||||
const session = authStore.generation
|
||||
@@ -60,5 +68,5 @@ export function useToast() {
|
||||
}
|
||||
}
|
||||
|
||||
return { success, progress, dismiss: store.dismiss }
|
||||
return { success, error, progress, dismiss: store.dismiss }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
|
||||
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
|
||||
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
|
||||
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 새 메일 · 다른 창의 변화를 맞춘다(실시간 소식 — 끊겼으면 1분 확인),
|
||||
// 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계" · "5단계 › 실시간 반영"). 실시간 연결 자체는 앱 전체에서 돈다(App.vue)
|
||||
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
|
||||
@@ -24,13 +25,15 @@ void useMailSignatureStore().ensureSignature()
|
||||
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
|
||||
void useMailVacationStore().load()
|
||||
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다).
|
||||
// 숨은 탭은 받은편지함의 안 읽은 수만 고쳐 탭 제목 "(N) " 을 맞추고, 다시 보일 때 맞춘다
|
||||
useMailStatePolling({
|
||||
clock: useClock(),
|
||||
onChange: () => {
|
||||
mailStore.syncCounts()
|
||||
void mailStore.syncVisibleList()
|
||||
},
|
||||
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
|
||||
})
|
||||
useMailTabTitle()
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailNotifyStore } from '@/stores/mail-notify.store'
|
||||
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import { FakeNotification } from '@/test-utils/notification'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
|
||||
import MailNotifyPage from './MailNotifyPage.vue'
|
||||
|
||||
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때" · README "5단계")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
window.localStorage.clear()
|
||||
installFakeLocks()
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
vi.stubGlobal('Notification', FakeNotification)
|
||||
FakeNotification.reset('default')
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
uninstallFakeLocks()
|
||||
vi.unstubAllGlobals()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage() {
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/mail/notify')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailNotifyPage, { global: { plugins: [pinia, router] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function enableInput(wrapper: Awaited<ReturnType<typeof mountPage>>) {
|
||||
return wrapper.get('#notify-enabled').element as HTMLInputElement
|
||||
}
|
||||
|
||||
it('제목 · 설명 — 새 메일은 1분을 기다리지 않고 바로 목록에 나타난다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('알림')
|
||||
expect(wrapper.get('main').attributes('aria-labelledby')).toBe('notify-page-title')
|
||||
expect(wrapper.text()).toContain('새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다.')
|
||||
})
|
||||
|
||||
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … 「허용」을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(enableInput(wrapper).checked).toBe(false)
|
||||
expect(enableInput(wrapper).disabled).toBe(false)
|
||||
expect(wrapper.get('#notify-enabled-note').text()).toBe(
|
||||
'켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.',
|
||||
)
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('켜기 → 브라우저가 묻고 허용 → 체크 · 초록 "허용되어 있습니다" · 시험 알림은 지금 탭에서 바로', async () => {
|
||||
const wrapper = await mountPage()
|
||||
FakeNotification.nextAnswer = 'granted'
|
||||
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeNotification.requestCount).toBe(1)
|
||||
expect(enableInput(wrapper).checked).toBe(true)
|
||||
const note = wrapper.get('#notify-enabled-note')
|
||||
expect(note.text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
|
||||
expect(note.classes()).toContain('notify-form__note--success')
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeUndefined()
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(true)
|
||||
|
||||
await wrapper.get('#notify-test').trigger('click')
|
||||
expect(FakeNotification.shown.map((shown) => [shown.title, shown.options.body])).toEqual([
|
||||
['ARA 웹메일', '알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다.'],
|
||||
])
|
||||
})
|
||||
|
||||
it('켜기 → 막음 → 칸을 끄고(누를 수 없다) 빨간 안내로 푸는 방법 — 초점은 그 안내 글로', async () => {
|
||||
const wrapper = await mountPage()
|
||||
FakeNotification.nextAnswer = 'denied'
|
||||
enableInput(wrapper).focus()
|
||||
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(enableInput(wrapper).checked).toBe(false)
|
||||
expect(enableInput(wrapper).disabled).toBe(true)
|
||||
const note = wrapper.get('#notify-enabled-note')
|
||||
expect(note.text()).toBe(
|
||||
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
|
||||
)
|
||||
expect(note.classes()).toContain('notify-form__note--danger')
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(false)
|
||||
expect(document.activeElement).toBe(note.element)
|
||||
})
|
||||
|
||||
it('허락을 묻는 동안 칸을 끄지 않고 aria-disabled 로만 막는다 — 초점이 남고, 다시 눌러도 다시 묻지 않는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const answer = FakeNotification.holdAnswer()
|
||||
enableInput(wrapper).focus()
|
||||
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
expect(enableInput(wrapper).disabled).toBe(false)
|
||||
expect(enableInput(wrapper).getAttribute('aria-disabled')).toBe('true')
|
||||
expect(document.activeElement).toBe(enableInput(wrapper))
|
||||
|
||||
// 묻는 창이 떠 있는 동안 다시 누른다 — 되돌리고 묻지 않는다
|
||||
await wrapper.get('#notify-enabled').setValue(false)
|
||||
await flushPromises()
|
||||
expect(FakeNotification.requestCount).toBe(1)
|
||||
|
||||
answer('granted')
|
||||
await flushPromises()
|
||||
expect(enableInput(wrapper).checked).toBe(true)
|
||||
expect(enableInput(wrapper).getAttribute('aria-disabled')).toBeNull()
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(true)
|
||||
expect(document.activeElement).toBe(enableInput(wrapper))
|
||||
})
|
||||
|
||||
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "지원하지 않습니다"로 바꾼다 — 초점은 안내 글로', async () => {
|
||||
FakeNotification.reset('granted')
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
FakeNotification.constructorThrows = true
|
||||
|
||||
await wrapper.get('#notify-test').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const note = wrapper.get('#notify-enabled-note')
|
||||
expect(note.text()).toBe('이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.')
|
||||
expect(enableInput(wrapper).disabled).toBe(true)
|
||||
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
|
||||
expect(document.activeElement).toBe(note.element)
|
||||
})
|
||||
|
||||
it('켜기 → 그냥 닫음 → 칸은 비운 채 그대로 (다시 누르면 또 묻는다)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
FakeNotification.nextAnswer = 'default'
|
||||
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(enableInput(wrapper).checked).toBe(false)
|
||||
expect(wrapper.get('#notify-enabled-note').attributes('data-note')).toBe('ask')
|
||||
|
||||
FakeNotification.nextAnswer = 'granted'
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
expect(FakeNotification.requestCount).toBe(2)
|
||||
expect(enableInput(wrapper).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('끄기 — 체크를 풀면 꺼지고 보일 것 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
|
||||
FakeNotification.reset('granted')
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
expect(enableInput(wrapper).checked).toBe(true)
|
||||
|
||||
await wrapper.get('#notify-enabled').setValue(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(useMailNotifyStore().settings.enabled).toBe(false)
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#notify-enabled-note').text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
|
||||
})
|
||||
|
||||
it('지원하지 않는 브라우저 — 칸을 끄고 "지원하지 않습니다. 새 메일은 그래도 바로 목록에"', async () => {
|
||||
vi.stubGlobal('Notification', undefined)
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(enableInput(wrapper).disabled).toBe(true)
|
||||
expect(wrapper.get('#notify-enabled-note').text()).toBe(
|
||||
'이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 보낸 사람 이름만 보입니다")', async () => {
|
||||
FakeNotification.reset('granted')
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
await flushPromises()
|
||||
|
||||
const subject = wrapper.get('#notify-show-subject')
|
||||
expect((subject.element as HTMLInputElement).checked).toBe(true)
|
||||
expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.')
|
||||
await subject.setValue(false)
|
||||
|
||||
expect(useMailNotifyStore().settings.showSubject).toBe(false)
|
||||
})
|
||||
|
||||
it('실시간 연결 — 연결됨(초록 점) · 끊김 "연결할 수 없어 1분마다 확인합니다." · 잇는 중', async () => {
|
||||
const realtime = useMailRealtimeStore()
|
||||
realtime.setStatus('live')
|
||||
const wrapper = await mountPage()
|
||||
const status = () => wrapper.get('#notify-realtime-status')
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('실시간 연결')
|
||||
expect(status().text()).toBe('연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.')
|
||||
expect(status().find('.realtime-link__dot').classes()).toContain('is-live')
|
||||
|
||||
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
|
||||
|
||||
realtime.setStatus('down')
|
||||
await flushPromises()
|
||||
expect(status().text()).toBe('연결할 수 없어 1분마다 확인합니다.')
|
||||
expect(status().find('.realtime-link__dot').classes()).not.toContain('is-live')
|
||||
// 끊긴 동안 — 상태 줄 바로 아래, "저절로 다시 잇습니다…" 안내 앞
|
||||
expect(wrapper.get('#notify-realtime-notice').text()).toBe('연결될 때까지 바탕화면 알림은 오지 않습니다.')
|
||||
expect(wrapper.find('[role="status"] #notify-realtime-notice').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('저절로 다시 잇습니다.')
|
||||
|
||||
realtime.setStatus('connecting')
|
||||
await flushPromises()
|
||||
expect(status().text()).toBe('연결하는 중입니다…')
|
||||
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('아래 안내 상자 — 이 브라우저에만 · 탭이 하나라도 열려 있어야 · 보고 있을 때는 보내지 않음', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const notes = wrapper.get('[data-notify-notes]')
|
||||
|
||||
expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([
|
||||
'알림 설정은 이 브라우저에만 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.',
|
||||
'웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
|
||||
'웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.',
|
||||
])
|
||||
expect(notes.get('strong').text()).toBe('이 브라우저에만')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · README "5단계 › 설정 › 메일 › 알림") — /settings/mail/notify
|
||||
// 위에서부터 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
|
||||
// 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
|
||||
// 설정은 이 브라우저 · 사용자마다 저장한다(mail-notify.store). 들어올 때 · 탭으로 돌아올 때 브라우저의 허락을 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다.
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
import NotifySettingsForm from '@/components/settings/NotifySettingsForm.vue'
|
||||
import RealtimeLinkStatus from '@/components/settings/RealtimeLinkStatus.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
|
||||
import { useMailNotifyStore } from '@/stores/mail-notify.store'
|
||||
|
||||
const notifyStore = useMailNotifyStore()
|
||||
|
||||
function refreshPermission() {
|
||||
if (document.visibilityState === 'visible') notifyStore.refreshPermission()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
notifyStore.refreshPermission()
|
||||
window.addEventListener('focus', refreshPermission)
|
||||
document.addEventListener('visibilitychange', refreshPermission)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', refreshPermission)
|
||||
document.removeEventListener('visibilitychange', refreshPermission)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="notify-page-title"
|
||||
:title="MAIL_NOTIFY_TEXT.title"
|
||||
:description="MAIL_NOTIFY_TEXT.description"
|
||||
>
|
||||
<NotifySettingsForm />
|
||||
<hr class="notify-page__rule">
|
||||
<RealtimeLinkStatus />
|
||||
<div
|
||||
class="notify-page__notes"
|
||||
data-notify-notes
|
||||
>
|
||||
<p class="notify-page__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ MAIL_NOTIFY_TEXT.noteBrowserBefore }}<strong class="notify-page__strong">{{
|
||||
MAIL_NOTIFY_TEXT.noteBrowserStrong
|
||||
}}</strong>{{ MAIL_NOTIFY_TEXT.noteBrowserAfter }}</span>
|
||||
</p>
|
||||
<p class="notify-page__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ MAIL_NOTIFY_TEXT.noteOpenTab }}</span>
|
||||
</p>
|
||||
<p class="notify-page__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ MAIL_NOTIFY_TEXT.noteVisible }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notify-page__rule {
|
||||
width: 100%;
|
||||
height: var(--border-width);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.notify-page__notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
max-width: var(--note-max-width);
|
||||
padding: var(--space-14) var(--space-16);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.notify-page__note {
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notify-page__strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -88,10 +88,13 @@ reload()
|
||||
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
/* 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다 — 다른 설정 화면(SettingsPageFrame)과 같다. 서명 칸 · 미리 보기는 보내는 모양 그대로 둔다 */
|
||||
.signature-page__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.signature-page__title {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SettingsLayout from './SettingsLayout.vue'
|
||||
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴")
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계")
|
||||
|
||||
describe('SettingsLayout', () => {
|
||||
let pinia: Pinia
|
||||
@@ -34,7 +34,7 @@ describe('SettingsLayout', () => {
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답), 묶음 이름이 목록의 이름이다', async () => {
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/account/password')
|
||||
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('SettingsLayout', () => {
|
||||
[
|
||||
['서명', '/settings/mail/signature'],
|
||||
['부재중 자동 응답', '/settings/mail/vacation'],
|
||||
['알림', '/settings/mail/notify'],
|
||||
],
|
||||
])
|
||||
})
|
||||
@@ -66,5 +67,6 @@ describe('SettingsLayout', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/account/password')
|
||||
expect(router.currentRoute.value.meta.title).toBe('비밀번호')
|
||||
expect(router.resolve({ name: 'settings-mail-vacation' }).fullPath).toBe('/settings/mail/vacation')
|
||||
expect(router.resolve({ name: 'settings-mail-notify' }).fullPath).toBe('/settings/mail/notify')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
|
||||
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴", 목록은 settings-menu.ts).
|
||||
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계", 목록은 settings-menu.ts).
|
||||
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
|
||||
@@ -47,7 +47,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답) + 하위 화면.
|
||||
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림) + 하위 화면.
|
||||
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
|
||||
path: '/settings',
|
||||
component: () => import('@/pages/settings/SettingsLayout.vue'),
|
||||
@@ -72,6 +72,12 @@ export const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/settings/MailVacationPage.vue'),
|
||||
meta: { title: '부재중 자동 응답' },
|
||||
},
|
||||
{
|
||||
path: 'mail/notify',
|
||||
name: SETTINGS_ROUTES.notify,
|
||||
component: () => import('@/pages/settings/MailNotifyPage.vue'),
|
||||
meta: { title: '메일 알림' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -248,6 +248,10 @@ export const ICONS = {
|
||||
],
|
||||
/** 메일함 패널의 "새 메일함" */
|
||||
plus: [line(12, 5, 12, 19), line(5, 12, 19, 12)],
|
||||
|
||||
// ── 5단계: 실시간 새 메일 · 바탕화면 알림 (디자인 원본 SettingsNotify · MailRealtime) ──
|
||||
/** 설정 메뉴의 알림 · 시험 알림 · 목록 위 권하기 줄 */
|
||||
bell: [path('M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9'), path('M13.73 21a2 2 0 0 1-3.46 0')],
|
||||
} as const satisfies Record<string, readonly IconShape[]>
|
||||
|
||||
export type IconName = keyof typeof ICONS
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 실시간 새 메일 · 바탕화면 알림 상수 — 계약 2절 "실시간 알림 (GET /api/mail-events)" · 디자인 README "5단계"
|
||||
// (디자인 원본 SettingsNotify · MailRealtime). 시간 값은 계약과 같다 — 서버는 25초마다 ping 을 보낸다.
|
||||
|
||||
// ── 실시간 연결 ─────────────────────────────────────────
|
||||
/** SSE 주소 — 같은 주소(origin)의 /api 아래라 쿠키가 함께 간다 (buildApiUrl 로 만든다) */
|
||||
export const MAIL_EVENTS_PATH = '/mail-events'
|
||||
/** SSE 응답의 Content-Type — 다르면(프록시가 index.html 을 돌려준 경우 등) 실패로 본다 */
|
||||
export const MAIL_EVENTS_CONTENT_TYPE = 'text/event-stream'
|
||||
/** 이만큼 ping · state 가 없으면 끊긴 것으로 본다 (서버 ping 25초의 세 번) — 연결한 탭은 연결을 끊고 다시 잇는다 (머리글을 기다리는 동안도) */
|
||||
export const MAIL_EVENTS_SILENCE_MS = 75_000
|
||||
/**
|
||||
* 끊긴 뒤 다시 잇기까지 — 실패할수록 길게, 마지막 값에서 멈춘다.
|
||||
* 연결이 자리 잡으면(MAIL_EVENTS_STABLE_MS) 처음부터 다시 센다 — 열리자마자 끊기는 연결은 간격이 계속 늘어난다
|
||||
*/
|
||||
export const MAIL_EVENTS_RECONNECT_DELAYS_MS: readonly number[] = [2_000, 5_000, 15_000, 30_000, 60_000]
|
||||
/** 다시 잇는 간격의 흔들림(±20%) — 서버가 다시 뜰 때 브라우저들이 한꺼번에 몰리지 않게 */
|
||||
export const MAIL_EVENTS_RECONNECT_JITTER = 0.2
|
||||
/**
|
||||
* 연결이 자리 잡았다고 보는 때 — 첫 state 뒤 정상 ping(upstream: true)을 받았거나 이만큼 이어졌을 때.
|
||||
* 그 전에 끊기거나 end 가 오면(까닭과 상관없이) 실패처럼 간격을 둔다 — 열리자마자 끝나는 연결(세션 끝 무렵 서버가 state 뒤 바로
|
||||
* end(token-expiring) 하는 등)을 왕복 시간 속도로 되풀이해 갱신 한도를 다 쓰지 않게 (2026-10-28 리뷰)
|
||||
*/
|
||||
export const MAIL_EVENTS_STABLE_MS = 20_000
|
||||
/** 자리 잡은 연결이 end(서버 종료 · 재시작)로 끝났을 때 다시 잇기 전의 흔들림 — 0~3초 (배포 때 모든 브라우저가 같은 순간에 붙지 않게) */
|
||||
export const MAIL_EVENTS_SHUTDOWN_SPREAD_MS = 3_000
|
||||
/** end 의 까닭 — 액세스 토큰이 곧 끝난다: 세션을 갱신한 뒤 다시 잇는다 (그 밖의 까닭은 조금 흔들어 다시 잇는다) */
|
||||
export const MAIL_EVENTS_END_TOKEN_EXPIRING = 'token-expiring'
|
||||
/**
|
||||
* 실시간 소식으로 바뀐 것을 메일 화면이 맞추기 전에 모으는 시간 — 첫 바뀜부터 이만큼 기다렸다 한 번에 (목록 · 개수를 다시 받는다).
|
||||
* 내가 한 통만 읽어도 state 가 바뀌어, 바뀔 때마다 보이는 탭마다 다시 받으면 요청이 많다 (1분 확인이 본 바뀜은 기다리지 않는다)
|
||||
*/
|
||||
export const MAIL_REALTIME_SYNC_DELAY_MS = 1_000
|
||||
|
||||
/**
|
||||
* 같은 브라우저의 탭끼리 쓰는 이름 — 잠금(Web Locks)으로 한 탭만 연결하고, 소식은 채널(BroadcastChannel)로 나눈다.
|
||||
* 잠금 이름 끝에 사용자 주소를 붙인다 (mailEventsLockName). 채널 메시지에도 주소를 넣어 다른 계정의 소식은 버린다
|
||||
*/
|
||||
export const MAIL_EVENTS_LOCK_PREFIX = 'arasystem.mail-events:'
|
||||
export const MAIL_EVENTS_CHANNEL_NAME = 'arasystem.mail-events'
|
||||
/** 새 메일을 받은 연결 탭이 "사용자가 보고 있는 탭이 있는지"(보이고 초점이 있음) 다른 탭의 답을 기다리는 시간 — 넘으면 없는 것으로 본다 */
|
||||
export const MAIL_EVENTS_VISIBILITY_WAIT_MS = 300
|
||||
|
||||
// ── 바탕화면 알림 ────────────────────────────────────────
|
||||
/** 하나의 tag 로 앞 알림을 바꿔 쓴다 — 바탕화면에 쌓이지 않는다 (renotify 로 다시 알린다) */
|
||||
export const DESKTOP_NOTIFY_TAG = 'arasystem-new-mail'
|
||||
/** 설정(켜기 · 제목 보이기 · 권하기 닫음)을 브라우저에 둔다 — 키 끝에 사용자 주소 (mail-view.store 와 같은 방식) */
|
||||
export const MAIL_NOTIFY_STORAGE_PREFIX = 'arasystem.mail.notify:'
|
||||
/** 여러 통 알림의 "박서준 · 이도윤 외 1명" — 이름을 몇 명까지 보이는지 */
|
||||
export const DESKTOP_NOTIFY_NAMED_SENDERS = 2
|
||||
|
||||
export const MAIL_NOTIFY_TEXT = {
|
||||
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify)
|
||||
menu: '알림',
|
||||
title: '알림',
|
||||
description:
|
||||
'새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 두세요.',
|
||||
enable: '새 메일이 오면 바탕화면 알림 보내기',
|
||||
noteAsk: '켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.',
|
||||
noteGranted: '이 브라우저에서 알림이 허용되어 있습니다.',
|
||||
noteBlocked:
|
||||
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
|
||||
noteUnsupported: '이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
|
||||
contentLegend: '알림에 보일 것',
|
||||
showSubject: '메일 제목도 보이기',
|
||||
showSubjectHint: '끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.',
|
||||
test: '시험 알림 보내기',
|
||||
testHint: '바탕화면 오른쪽 아래(맥은 오른쪽 위)에 알림이 뜨면 됩니다.',
|
||||
realtimeTitle: '실시간 연결',
|
||||
realtimeLive: '연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.',
|
||||
realtimeLiveHint:
|
||||
'연결할 수 없을 때는 "연결할 수 없어 1분마다 확인합니다."로 바뀌고, 저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
|
||||
realtimeDown: '연결할 수 없어 1분마다 확인합니다.',
|
||||
/** 끊긴 동안 — 상태 줄 바로 아래 (2026-10-28 화면 점검 반영) */
|
||||
realtimeDownNotice: '연결될 때까지 바탕화면 알림은 오지 않습니다.',
|
||||
realtimeDownHint: '저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
|
||||
/** 처음 잇는 동안 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
|
||||
realtimeConnecting: '연결하는 중입니다…',
|
||||
/** Web Locks · BroadcastChannel 이 없는 브라우저 — 1분 확인만 한다 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
|
||||
realtimeUnsupportedHint: '이 브라우저는 실시간 연결을 지원하지 않습니다.',
|
||||
/** 아래 안내 상자 — 첫 줄의 "이 브라우저에만"은 굵게 */
|
||||
noteBrowserBefore: '알림 설정은 ',
|
||||
noteBrowserStrong: '이 브라우저에만',
|
||||
noteBrowserAfter: ' 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.',
|
||||
noteOpenTab: '웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
|
||||
noteVisible: '웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.',
|
||||
|
||||
// 시험 알림 — 지금 탭에서 바로 보낸다
|
||||
testTitle: 'ARA 웹메일',
|
||||
testBody: '알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다.',
|
||||
|
||||
// 새 메일 알림 (디자인 원본 MailRealtime)
|
||||
/** 「메일 제목도 보이기」를 껐을 때 한 통 알림의 내용 */
|
||||
hiddenSubject: '새 메일이 왔습니다',
|
||||
manyTitle: (count: string) => `새 메일 ${count}통`,
|
||||
/** "박서준 · 이도윤 외 1명" — names 는 이미 " · " 로 이은 이름들 */
|
||||
sendersMore: (names: string, rest: string) => `${names} 외 ${rest}명`,
|
||||
senderSeparator: ' · ',
|
||||
|
||||
// 목록 위에서 한 번 권하기 (디자인 원본 MailRealtime)
|
||||
offerRegion: '바탕화면 알림 권하기',
|
||||
offerText: '바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.',
|
||||
/** 분할 보기의 좁은 목록 칸(384)에서 — 긴 글은 title 로 (2026-10-28 화면 점검 반영) */
|
||||
offerShortText: '바탕화면 알림으로 새 메일 받기',
|
||||
offerAction: '알림 켜기',
|
||||
offerClose: '닫기',
|
||||
/** 허락을 받아 켰을 때 (성공 알림) */
|
||||
enabledToast: '바탕화면 알림을 켰습니다.',
|
||||
/** 브라우저가 막았을 때 (오류 알림 + 설정 열기) */
|
||||
blockedToast: '브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.',
|
||||
openSettings: '설정 열기',
|
||||
} as const
|
||||
@@ -196,7 +196,7 @@ export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
|
||||
export const MAIL_NO_SUBJECT = '(제목 없음)'
|
||||
|
||||
// ── 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일") ─────────────
|
||||
/** 화면이 보이는 동안 이 간격으로 바뀐 것이 있는지 묻는다 */
|
||||
/** 화면이 보이는 동안 이 간격으로 바뀐 것이 있는지 묻는다 — 실시간 연결이 살아 있으면 묻지 않는다 (5단계, mail-notify.ts) */
|
||||
export const MAIL_STATE_POLL_INTERVAL_MS = 60_000
|
||||
/** 탭이 다시 보일 때 마지막으로 물은 지 이만큼 지났으면 바로 한 번 묻는다 */
|
||||
export const MAIL_STATE_RECHECK_GAP_MS = 10_000
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴")
|
||||
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내)가 이 이름을 함께 쓴다.
|
||||
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림")
|
||||
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기])가
|
||||
// 이 이름을 함께 쓴다.
|
||||
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
@@ -8,6 +9,7 @@ export const SETTINGS_ROUTES = {
|
||||
password: 'settings-account-password',
|
||||
signature: 'settings-mail-signature',
|
||||
vacation: 'settings-mail-vacation',
|
||||
notify: 'settings-mail-notify',
|
||||
} as const
|
||||
|
||||
export interface SettingsMenuItem {
|
||||
@@ -35,6 +37,7 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
|
||||
items: [
|
||||
{ routeName: SETTINGS_ROUTES.signature, label: '서명', icon: 'edit' },
|
||||
{ routeName: SETTINGS_ROUTES.vacation, label: '부재중 자동 응답', icon: 'calendar-x' },
|
||||
{ routeName: SETTINGS_ROUTES.notify, label: '알림', icon: 'bell' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { supportsDesktopNotification, supportsMailRealtime, type BrowserScope } from './browser-support'
|
||||
|
||||
// 실시간 새 메일 · 바탕화면 알림을 쓸 수 있는 브라우저인지 (계약 2절 "실시간 알림" — Web Locks 가 없으면 1분 확인만)
|
||||
|
||||
const fn = () => undefined
|
||||
|
||||
function fullScope(): BrowserScope {
|
||||
return {
|
||||
navigator: { locks: { request: fn } },
|
||||
BroadcastChannel: fn,
|
||||
fetch: fn,
|
||||
ReadableStream: fn,
|
||||
TextDecoder: fn,
|
||||
AbortController: fn,
|
||||
Notification: fn,
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser-support', () => {
|
||||
it('Web Locks · BroadcastChannel · fetch 스트림이 다 있으면 실시간을 쓴다', () => {
|
||||
expect(supportsMailRealtime(fullScope())).toBe(true)
|
||||
})
|
||||
|
||||
it('하나라도 없으면 실시간을 쓰지 않는다 (지금의 1분 확인만)', () => {
|
||||
expect(supportsMailRealtime({ ...fullScope(), navigator: {} })).toBe(false)
|
||||
expect(supportsMailRealtime({ ...fullScope(), navigator: { locks: {} } })).toBe(false)
|
||||
expect(supportsMailRealtime({ ...fullScope(), BroadcastChannel: undefined })).toBe(false)
|
||||
expect(supportsMailRealtime({ ...fullScope(), ReadableStream: undefined })).toBe(false)
|
||||
expect(supportsMailRealtime({})).toBe(false)
|
||||
})
|
||||
|
||||
it('바탕화면 알림은 Notification 과 실시간이 함께 있어야 한다', () => {
|
||||
expect(supportsDesktopNotification(fullScope())).toBe(true)
|
||||
expect(supportsDesktopNotification({ ...fullScope(), Notification: undefined })).toBe(false)
|
||||
expect(supportsDesktopNotification({ ...fullScope(), navigator: {} })).toBe(false)
|
||||
})
|
||||
|
||||
it('테스트 환경(jsdom)의 전역에는 Web Locks 가 없다 — 앱 전체 테스트는 1분 확인만 한다', () => {
|
||||
expect(supportsMailRealtime()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
// 이 브라우저가 쓸 수 있는 기능 — 전역 객체를 받아 본다 (기본은 globalThis, 테스트는 가짜 전역을 넘긴다)
|
||||
// 실시간 새 메일(계약 2절 "실시간 알림")은 Web Locks(탭 하나만 연결) · BroadcastChannel(소식 나누기) · fetch 스트림이 다 있어야 한다.
|
||||
// 하나라도 없으면 실시간 없이 지금의 1분 확인만 한다. 바탕화면 알림은 실시간 소식으로만 오므로 실시간도 쓸 수 있어야 한다.
|
||||
|
||||
/** 보는 것만 적은 전역 — 값이 함수인지만 확인한다 */
|
||||
export interface BrowserScope {
|
||||
navigator?: { locks?: { request?: unknown } }
|
||||
BroadcastChannel?: unknown
|
||||
fetch?: unknown
|
||||
ReadableStream?: unknown
|
||||
TextDecoder?: unknown
|
||||
AbortController?: unknown
|
||||
Notification?: unknown
|
||||
}
|
||||
|
||||
const browserGlobals = globalThis as unknown as BrowserScope
|
||||
|
||||
export function supportsMailRealtime(scope: BrowserScope = browserGlobals): boolean {
|
||||
return (
|
||||
typeof scope.navigator?.locks?.request === 'function' &&
|
||||
typeof scope.BroadcastChannel === 'function' &&
|
||||
typeof scope.fetch === 'function' &&
|
||||
typeof scope.ReadableStream === 'function' &&
|
||||
typeof scope.TextDecoder === 'function' &&
|
||||
typeof scope.AbortController === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
export function supportsDesktopNotification(scope: BrowserScope = browserGlobals): boolean {
|
||||
return typeof scope.Notification === 'function' && supportsMailRealtime(scope)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NewMailItem, NewMailSignal } from './mail-events'
|
||||
import {
|
||||
DEFAULT_MAIL_NOTIFY_SETTINGS,
|
||||
newMailNotificationContent,
|
||||
newMailSenders,
|
||||
notifyEnableState,
|
||||
parseMailNotifySettings,
|
||||
shouldOfferDesktopNotify,
|
||||
} from './desktop-notify'
|
||||
|
||||
// 바탕화면 알림의 규칙 (디자인 README "5단계 › 바탕화면 알림 · 목록 위에서 한 번 권하기 · 설정 › 메일 › 알림")
|
||||
|
||||
function item(id: string, name: string | null, email: string, subject = `제목 ${id}`): NewMailItem {
|
||||
return { id, from: { name, email }, subject, receivedAt: '2026-10-28T05:42:00.000Z' }
|
||||
}
|
||||
|
||||
function signal(messages: NewMailItem[], count = messages.length): NewMailSignal {
|
||||
return { count, messages }
|
||||
}
|
||||
|
||||
const SEOJUN = 'seojun.park@arasystem.it.kr'
|
||||
const DOYUN = 'doyun.lee@arasystem.it.kr'
|
||||
const JIHO = 'jiho.choi@arasystem.it.kr'
|
||||
|
||||
describe('newMailNotificationContent (알림 제목 · 내용)', () => {
|
||||
it('한 통 — 제목은 보낸 사람 이름, 내용은 메일 제목, 누르면 그 메일', () => {
|
||||
const content = newMailNotificationContent(signal([item('m1', '박서준', SEOJUN, '11월 납품분 견적서 보내 드립니다')]), {
|
||||
showSubject: true,
|
||||
})
|
||||
|
||||
expect(content).toEqual({
|
||||
title: '박서준',
|
||||
body: '11월 납품분 견적서 보내 드립니다',
|
||||
target: { kind: 'message', messageId: 'm1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('한 통 — 이름이 없으면 주소, 제목이 비었으면 목록과 같은 "(제목 없음)"', () => {
|
||||
expect(newMailNotificationContent(signal([item('m1', null, SEOJUN, '')]), { showSubject: true })).toMatchObject({
|
||||
title: SEOJUN,
|
||||
body: '(제목 없음)',
|
||||
})
|
||||
expect(newMailNotificationContent(signal([item('m1', ' ', SEOJUN, ' ')]), { showSubject: true })).toMatchObject({
|
||||
title: SEOJUN,
|
||||
body: '(제목 없음)',
|
||||
})
|
||||
})
|
||||
|
||||
it('한 통 — 「메일 제목도 보이기」를 끄면 내용은 "새 메일이 왔습니다"', () => {
|
||||
expect(newMailNotificationContent(signal([item('m1', '박서준', SEOJUN, '비밀 제목')]), { showSubject: false })).toEqual({
|
||||
title: '박서준',
|
||||
body: '새 메일이 왔습니다',
|
||||
target: { kind: 'message', messageId: 'm1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('여러 통 — "새 메일 N통" · "박서준 · 이도윤 외 1명", 누르면 받은편지함 (제목 숨김이어도 같다)', () => {
|
||||
const three = signal([item('m3', '박서준', SEOJUN), item('m2', '이도윤', DOYUN), item('m1', '최지호', JIHO)])
|
||||
|
||||
for (const showSubject of [true, false]) {
|
||||
expect(newMailNotificationContent(three, { showSubject })).toEqual({
|
||||
title: '새 메일 3통',
|
||||
body: '박서준 · 이도윤 외 1명',
|
||||
target: { kind: 'inbox' },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('여러 통 — 전체 수는 count 로 센다 (서버는 새것부터 5통까지만 보낸다)', () => {
|
||||
const many = signal([item('m9', '박서준', SEOJUN), item('m8', '이도윤', DOYUN)], 1234)
|
||||
|
||||
expect(newMailNotificationContent(many, { showSubject: true })).toMatchObject({
|
||||
title: '새 메일 1,234통',
|
||||
body: '박서준 · 이도윤',
|
||||
})
|
||||
})
|
||||
|
||||
it('보낼 것이 없으면 null', () => {
|
||||
expect(newMailNotificationContent(signal([], 0), { showSubject: true })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('newMailSenders (여러 통의 보낸 사람)', () => {
|
||||
it('같은 사람은 한 번만 — 둘까지 이름, 나머지는 사람 수', () => {
|
||||
expect(newMailSenders(signal([item('m3', '박서준', SEOJUN), item('m2', '박서준', SEOJUN.toUpperCase())]))).toBe('박서준')
|
||||
expect(newMailSenders(signal([item('m3', '박서준', SEOJUN), item('m2', null, DOYUN)]))).toBe(`박서준 · ${DOYUN}`)
|
||||
expect(
|
||||
newMailSenders(
|
||||
signal([
|
||||
item('m5', '박서준', SEOJUN),
|
||||
item('m4', '이도윤', DOYUN),
|
||||
item('m3', '박서준', SEOJUN),
|
||||
item('m2', '최지호', JIHO),
|
||||
item('m1', '한예린', 'yerin.han@arasystem.it.kr'),
|
||||
]),
|
||||
),
|
||||
).toBe('박서준 · 이도윤 외 2명')
|
||||
})
|
||||
|
||||
it('보낸 사람이 없는 메일은 건너뛴다', () => {
|
||||
expect(
|
||||
newMailSenders(signal([{ id: 'm2', from: null, subject: '', receivedAt: '' }, item('m1', '이도윤', DOYUN)])),
|
||||
).toBe('이도윤')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldOfferDesktopNotify (목록 위에서 한 번 권하기)', () => {
|
||||
const settings = { enabled: false, offerDismissed: false }
|
||||
|
||||
it('브라우저가 아직 묻지 않았고 · 켜지 않았고 · 닫은 적이 없을 때만 보인다', () => {
|
||||
expect(shouldOfferDesktopNotify({ permission: 'default', settings })).toBe(true)
|
||||
expect(shouldOfferDesktopNotify({ permission: 'default', settings: { ...settings, enabled: true } })).toBe(false)
|
||||
expect(shouldOfferDesktopNotify({ permission: 'default', settings: { ...settings, offerDismissed: true } })).toBe(false)
|
||||
})
|
||||
|
||||
it('브라우저가 이미 막았거나 · 허락했거나 · 지원하지 않으면 보이지 않는다', () => {
|
||||
expect(shouldOfferDesktopNotify({ permission: 'denied', settings })).toBe(false)
|
||||
expect(shouldOfferDesktopNotify({ permission: 'granted', settings })).toBe(false)
|
||||
expect(shouldOfferDesktopNotify({ permission: 'unsupported', settings })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('notifyEnableState (설정의 켜기 칸)', () => {
|
||||
it('묻기 전 — 칸은 비어 있고 누를 수 있다 (켠 적이 있어도 브라우저가 허락을 거두었으면 비운다)', () => {
|
||||
expect(notifyEnableState('default', { enabled: false })).toEqual({ checked: false, disabled: false, note: 'ask' })
|
||||
expect(notifyEnableState('default', { enabled: true })).toEqual({ checked: false, disabled: false, note: 'ask' })
|
||||
})
|
||||
|
||||
it('허용됨 — 켰으면 체크, 꺼 두었으면 비운 채 "허용되어 있습니다"', () => {
|
||||
expect(notifyEnableState('granted', { enabled: true })).toEqual({ checked: true, disabled: false, note: 'granted' })
|
||||
expect(notifyEnableState('granted', { enabled: false })).toEqual({ checked: false, disabled: false, note: 'granted' })
|
||||
})
|
||||
|
||||
it('막힘 · 지원하지 않음 — 칸을 끄고 누를 수 없다', () => {
|
||||
expect(notifyEnableState('denied', { enabled: true })).toEqual({ checked: false, disabled: true, note: 'blocked' })
|
||||
expect(notifyEnableState('unsupported', { enabled: false })).toEqual({
|
||||
checked: false,
|
||||
disabled: true,
|
||||
note: 'unsupported',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMailNotifySettings (저장한 설정 읽기)', () => {
|
||||
it('없거나 깨졌으면 기본값 — 꺼짐 · 제목 보이기 · 권하기 닫지 않음', () => {
|
||||
expect(DEFAULT_MAIL_NOTIFY_SETTINGS).toEqual({ enabled: false, showSubject: true, offerDismissed: false })
|
||||
expect(parseMailNotifySettings(null)).toEqual(DEFAULT_MAIL_NOTIFY_SETTINGS)
|
||||
expect(parseMailNotifySettings('{깨짐')).toEqual(DEFAULT_MAIL_NOTIFY_SETTINGS)
|
||||
expect(parseMailNotifySettings('"on"')).toEqual(DEFAULT_MAIL_NOTIFY_SETTINGS)
|
||||
})
|
||||
|
||||
it('항목마다 읽고, 모양이 틀린 항목만 기본값', () => {
|
||||
expect(parseMailNotifySettings('{"enabled":true,"showSubject":false,"offerDismissed":true}')).toEqual({
|
||||
enabled: true,
|
||||
showSubject: false,
|
||||
offerDismissed: true,
|
||||
})
|
||||
expect(parseMailNotifySettings('{"enabled":"yes","showSubject":false}')).toEqual({
|
||||
enabled: false,
|
||||
showSubject: false,
|
||||
offerDismissed: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
// 바탕화면 알림의 규칙 — 알림 제목 · 내용, 목록 위 권하기를 보일지, 설정 화면의 켜기 칸, 저장한 설정 읽기 (순수 함수만 둔다)
|
||||
// 디자인 README "5단계 › 바탕화면 알림 · 목록 위에서 한 번 권하기 · 설정 › 메일 › 알림" · 계약 2절 "실시간 알림"
|
||||
// 알림을 띄우는 일은 composables/useDesktopNotification.ts, 보낼지 정하는 일은 composables/useNewMailNotifier.ts 가 한다.
|
||||
|
||||
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { DESKTOP_NOTIFY_NAMED_SENDERS, MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
|
||||
import { displayName, normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import type { NewMailSignal } from '@/shared/utils/mail-events'
|
||||
|
||||
/** 브라우저의 알림 허락 — 지원하지 않으면 'unsupported' */
|
||||
export type DesktopNotifyPermission = NotificationPermission | 'unsupported'
|
||||
|
||||
/** 브라우저 · 사용자마다 저장하는 설정 (localStorage) */
|
||||
export interface MailNotifySettings {
|
||||
/** 새 메일이 오면 바탕화면 알림 보내기 — 기본 꺼짐 */
|
||||
enabled: boolean
|
||||
/** 메일 제목도 보이기 — 기본 켬 (끄면 보낸 사람만) */
|
||||
showSubject: boolean
|
||||
/** 목록 위의 권하기 줄을 닫았다 — 다시 보이지 않는다 */
|
||||
offerDismissed: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_MAIL_NOTIFY_SETTINGS: Readonly<MailNotifySettings> = {
|
||||
enabled: false,
|
||||
showSubject: true,
|
||||
offerDismissed: false,
|
||||
}
|
||||
|
||||
/** 저장한 값을 읽는다 — 없거나 깨졌으면 기본값, 항목마다 모양이 틀리면 그 항목만 기본값 */
|
||||
export function parseMailNotifySettings(raw: string | null): MailNotifySettings {
|
||||
if (raw === null) return { ...DEFAULT_MAIL_NOTIFY_SETTINGS }
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(raw)
|
||||
} catch {
|
||||
return { ...DEFAULT_MAIL_NOTIFY_SETTINGS }
|
||||
}
|
||||
if (typeof value !== 'object' || value === null) return { ...DEFAULT_MAIL_NOTIFY_SETTINGS }
|
||||
const stored = value as Partial<Record<keyof MailNotifySettings, unknown>>
|
||||
const pick = (key: keyof MailNotifySettings): boolean => {
|
||||
const field = stored[key]
|
||||
return typeof field === 'boolean' ? field : DEFAULT_MAIL_NOTIFY_SETTINGS[key]
|
||||
}
|
||||
return { enabled: pick('enabled'), showSubject: pick('showSubject'), offerDismissed: pick('offerDismissed') }
|
||||
}
|
||||
|
||||
// ── 새 메일 알림 ───────────────────────────────────────
|
||||
|
||||
/** 알림을 누르면 갈 곳 — 한 통이면 그 메일, 여러 통이면 받은편지함 */
|
||||
export type NewMailNotificationTarget = { kind: 'message'; messageId: string } | { kind: 'inbox' }
|
||||
|
||||
export interface NewMailNotificationContent {
|
||||
title: string
|
||||
body: string
|
||||
target: NewMailNotificationTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* "박서준 · 이도윤 외 1명" — 받은 메일(새것부터)의 보낸 사람을 겹치지 않게 앞에서 둘까지, 나머지는 사람 수로.
|
||||
* 서버가 보낸 메일(최대 5통)의 보낸 사람으로만 센다 — 그보다 많이 왔으면 나머지 메일의 보낸 사람은 알 수 없다
|
||||
*/
|
||||
export function newMailSenders(signal: NewMailSignal): string {
|
||||
const seen = new Set<string>()
|
||||
const names: string[] = []
|
||||
for (const message of signal.messages) {
|
||||
if (!message.from) continue
|
||||
const key = normalizeEmail(message.from.email)
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
names.push(displayName(message.from))
|
||||
}
|
||||
const shown = names.slice(0, DESKTOP_NOTIFY_NAMED_SENDERS).join(MAIL_NOTIFY_TEXT.senderSeparator)
|
||||
const rest = names.length - DESKTOP_NOTIFY_NAMED_SENDERS
|
||||
return rest > 0 ? MAIL_NOTIFY_TEXT.sendersMore(shown, formatCount(rest)) : shown
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 메일 알림의 제목 · 내용 (디자인 원본 MailRealtime)
|
||||
* - 한 통: 제목 = 보낸 사람(이름, 없으면 주소) · 내용 = 메일 제목(비면 목록과 같은 "(제목 없음)"). 제목 숨김이면 "새 메일이 왔습니다"
|
||||
* - 여러 통: "새 메일 3통" · "박서준 · 이도윤 외 1명" (제목 숨김이어도 그대로 — 제목이 들어가지 않는다)
|
||||
* 보낼 것이 없으면 null
|
||||
*/
|
||||
export function newMailNotificationContent(
|
||||
signal: NewMailSignal,
|
||||
options: { showSubject: boolean },
|
||||
): NewMailNotificationContent | null {
|
||||
if (signal.count <= 0) return null
|
||||
const [first] = signal.messages
|
||||
if (signal.count === 1 && first) {
|
||||
const title = displayName(first.from) || MAIL_NOTIFY_TEXT.manyTitle(formatCount(1))
|
||||
const subject = first.subject.trim() ? first.subject : MAIL_NO_SUBJECT
|
||||
return {
|
||||
title,
|
||||
body: options.showSubject ? subject : MAIL_NOTIFY_TEXT.hiddenSubject,
|
||||
target: { kind: 'message', messageId: first.id },
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: MAIL_NOTIFY_TEXT.manyTitle(formatCount(signal.count)),
|
||||
body: newMailSenders(signal),
|
||||
target: { kind: 'inbox' },
|
||||
}
|
||||
}
|
||||
|
||||
// ── 목록 위에서 한 번 권하기 · 설정 화면의 켜기 칸 ─────────────
|
||||
|
||||
/**
|
||||
* 목록 위의 권하기 줄을 보일지 — 지원하고, 브라우저가 아직 묻지 않았고(default), 켜지 않았고, 닫은 적이 없을 때.
|
||||
* 브라우저가 이미 막았거나(denied) 허락했으면(granted) 보이지 않는다
|
||||
*/
|
||||
export function shouldOfferDesktopNotify(state: {
|
||||
permission: DesktopNotifyPermission
|
||||
settings: Pick<MailNotifySettings, 'enabled' | 'offerDismissed'>
|
||||
}): boolean {
|
||||
return state.permission === 'default' && !state.settings.enabled && !state.settings.offerDismissed
|
||||
}
|
||||
|
||||
/** 켜기 칸 아래 안내 — 묻기 전 · 허용됨 · 막힘 · 지원하지 않음 */
|
||||
export type NotifyEnableNote = 'ask' | 'granted' | 'blocked' | 'unsupported'
|
||||
|
||||
export interface NotifyEnableState {
|
||||
/** 켜기 칸에 체크 — 설정을 켰고 브라우저가 허락했을 때만 */
|
||||
checked: boolean
|
||||
/** 켜기 칸을 누를 수 없다 — 막힘 · 지원하지 않음 */
|
||||
disabled: boolean
|
||||
note: NotifyEnableNote
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 › 메일 › 알림의 켜기 칸 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때").
|
||||
* 허락했는데 설정을 꺼 둔 채면 칸은 비우고 안내는 "허용되어 있습니다"다 — 다시 켤 때 브라우저가 묻지 않는다
|
||||
*/
|
||||
export function notifyEnableState(
|
||||
permission: DesktopNotifyPermission,
|
||||
settings: Pick<MailNotifySettings, 'enabled'>,
|
||||
): NotifyEnableState {
|
||||
if (permission === 'unsupported') return { checked: false, disabled: true, note: 'unsupported' }
|
||||
if (permission === 'denied') return { checked: false, disabled: true, note: 'blocked' }
|
||||
if (permission === 'granted') return { checked: settings.enabled, disabled: false, note: 'granted' }
|
||||
return { checked: false, disabled: false, note: 'ask' }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseMailEvent } from './mail-events'
|
||||
|
||||
// 실시간 알림의 서버 이벤트 (계약 2절 "실시간 알림") — 모양이 틀린 것은 버린다
|
||||
|
||||
const event = (name: string, data: string) => ({ event: name, data, id: null })
|
||||
|
||||
describe('parseMailEvent (실시간 알림의 서버 이벤트)', () => {
|
||||
it('state — state 와 받은편지함의 안 읽은 수', () => {
|
||||
expect(parseMailEvent(event('state', '{"state":"s1","inboxUnread":3}'))).toEqual({
|
||||
type: 'state',
|
||||
state: 's1',
|
||||
inboxUnread: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('state — 안 읽은 수가 없거나 이상하면 null, state 가 없으면 버린다', () => {
|
||||
expect(parseMailEvent(event('state', '{"state":"s1"}'))).toMatchObject({ inboxUnread: null })
|
||||
expect(parseMailEvent(event('state', '{"state":"s1","inboxUnread":-1}'))).toMatchObject({ inboxUnread: null })
|
||||
expect(parseMailEvent(event('state', '{"state":"s1","inboxUnread":1.5}'))).toMatchObject({ inboxUnread: null })
|
||||
expect(parseMailEvent(event('state', '{"inboxUnread":3}'))).toBeNull()
|
||||
expect(parseMailEvent(event('state', '{"state":""}'))).toBeNull()
|
||||
expect(parseMailEvent(event('state', 'not json'))).toBeNull()
|
||||
expect(parseMailEvent(event('state', '["s1"]'))).toBeNull()
|
||||
})
|
||||
|
||||
it('new-mail — 메일 목록과 전체 수 (보낸 사람 · 제목이 없으면 null · 빈 글)', () => {
|
||||
const parsed = parseMailEvent(
|
||||
event(
|
||||
'new-mail',
|
||||
JSON.stringify({
|
||||
count: 7,
|
||||
messages: [
|
||||
{
|
||||
id: 'm1',
|
||||
from: { name: '박서준', email: 'seojun.park@arasystem.it.kr' },
|
||||
subject: '견적서',
|
||||
receivedAt: '2026-10-28T05:42:00.000Z',
|
||||
},
|
||||
{ id: 'm2', from: null, receivedAt: '2026-10-28T05:41:00.000Z' },
|
||||
{ id: 'm3', from: { email: 'doyun.lee@arasystem.it.kr' }, subject: '' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: 'new-mail',
|
||||
count: 7,
|
||||
messages: [
|
||||
{
|
||||
id: 'm1',
|
||||
from: { name: '박서준', email: 'seojun.park@arasystem.it.kr' },
|
||||
subject: '견적서',
|
||||
receivedAt: '2026-10-28T05:42:00.000Z',
|
||||
},
|
||||
{ id: 'm2', from: null, subject: '', receivedAt: '2026-10-28T05:41:00.000Z' },
|
||||
{ id: 'm3', from: { name: null, email: 'doyun.lee@arasystem.it.kr' }, subject: '', receivedAt: '' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('new-mail — id 없는 메일은 빼고, 전체 수가 이상하면 받은 메일 수로 센다 · 하나도 없으면 버린다', () => {
|
||||
expect(
|
||||
parseMailEvent(event('new-mail', '{"count":"x","messages":[{"id":"m1"},{"subject":"id 없음"},null]}')),
|
||||
).toMatchObject({ count: 1, messages: [{ id: 'm1' }] })
|
||||
expect(parseMailEvent(event('new-mail', '{"count":0,"messages":[]}'))).toBeNull()
|
||||
expect(parseMailEvent(event('new-mail', '{"count":2}'))).toBeNull()
|
||||
})
|
||||
|
||||
it('ping — upstream: false 일 때만 끊김이다 (모양이 틀려도 살아 있다는 신호)', () => {
|
||||
expect(parseMailEvent(event('ping', '{"upstream":true}'))).toEqual({ type: 'ping', upstream: true })
|
||||
expect(parseMailEvent(event('ping', '{"upstream":false}'))).toEqual({ type: 'ping', upstream: false })
|
||||
expect(parseMailEvent(event('ping', ''))).toEqual({ type: 'ping', upstream: true })
|
||||
})
|
||||
|
||||
it('end — 까닭 (없으면 빈 글)', () => {
|
||||
expect(parseMailEvent(event('end', '{"reason":"token-expiring"}'))).toEqual({ type: 'end', reason: 'token-expiring' })
|
||||
expect(parseMailEvent(event('end', ''))).toEqual({ type: 'end', reason: '' })
|
||||
})
|
||||
|
||||
it('모르는 이벤트는 버린다', () => {
|
||||
expect(parseMailEvent(event('message', '{"state":"s1"}'))).toBeNull()
|
||||
expect(parseMailEvent(event('mailbox-changed', '{}'))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
// 실시간 알림의 서버 이벤트 읽기 — 계약 2절 "실시간 알림 (GET /api/mail-events)" (순수 함수만 둔다)
|
||||
// SSE 이벤트(sse-parser.ts)의 data(JSON)를 모양까지 확인해 아래 네 가지로 바꾼다. 모르는 이벤트 · 모양이 틀린 것은 null (버린다).
|
||||
// state — 메일함 · 메일이 바뀌었다 (GET /mail-state 의 state 와 같은 값) + 받은편지함의 안 읽은 수
|
||||
// new-mail — 받은편지함에 새로 배달된 메일 (새것부터 최대 5통, count 는 전체 수)
|
||||
// ping — 25초마다. upstream: false 면 서버와 메일 엔진 사이가 끊겨 다시 잇는 중이다
|
||||
// end — 서버가 곧 닫는다 (token-expiring · server-shutdown)
|
||||
|
||||
import type { Address } from '@/shared/types/mail'
|
||||
import type { SseEvent } from '@/shared/utils/sse-parser'
|
||||
|
||||
export interface MailStateSignal {
|
||||
state: string
|
||||
/** 받은편지함의 안 읽은 수 — 모르면 null */
|
||||
inboxUnread: number | null
|
||||
}
|
||||
|
||||
export interface NewMailItem {
|
||||
id: string
|
||||
from: Address | null
|
||||
/** 제목이 없으면 빈 문자열 */
|
||||
subject: string
|
||||
receivedAt: string
|
||||
}
|
||||
|
||||
export interface NewMailSignal {
|
||||
/** 새로 온 메일 수 — messages 보다 많을 수 있다 (서버는 새것부터 5통까지만 보낸다) */
|
||||
count: number
|
||||
messages: NewMailItem[]
|
||||
}
|
||||
|
||||
export type MailServerEvent =
|
||||
| ({ type: 'state' } & MailStateSignal)
|
||||
| ({ type: 'new-mail' } & NewMailSignal)
|
||||
| { type: 'ping'; upstream: boolean }
|
||||
| { type: 'end'; reason: string }
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
function parseObject(data: string): JsonObject | null {
|
||||
try {
|
||||
const value: unknown = JSON.parse(data)
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonObject) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isCount(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0
|
||||
}
|
||||
|
||||
function addressOf(value: unknown): Address | null {
|
||||
if (typeof value !== 'object' || value === null) return null
|
||||
const candidate = value as JsonObject
|
||||
if (typeof candidate.email !== 'string' || candidate.email.trim() === '') return null
|
||||
const name = typeof candidate.name === 'string' ? candidate.name : null
|
||||
return { name, email: candidate.email }
|
||||
}
|
||||
|
||||
function newMailItemOf(value: unknown): NewMailItem | null {
|
||||
if (typeof value !== 'object' || value === null) return null
|
||||
const candidate = value as JsonObject
|
||||
if (typeof candidate.id !== 'string' || candidate.id === '') return null
|
||||
return {
|
||||
id: candidate.id,
|
||||
from: addressOf(candidate.from),
|
||||
subject: typeof candidate.subject === 'string' ? candidate.subject : '',
|
||||
receivedAt: typeof candidate.receivedAt === 'string' ? candidate.receivedAt : '',
|
||||
}
|
||||
}
|
||||
|
||||
function stateOf(data: string): MailServerEvent | null {
|
||||
const body = parseObject(data)
|
||||
if (!body || typeof body.state !== 'string' || body.state === '') return null
|
||||
return { type: 'state', state: body.state, inboxUnread: isCount(body.inboxUnread) ? body.inboxUnread : null }
|
||||
}
|
||||
|
||||
function newMailOf(data: string): MailServerEvent | null {
|
||||
const body = parseObject(data)
|
||||
if (!body || !Array.isArray(body.messages)) return null
|
||||
const messages = body.messages.map(newMailItemOf).filter((item): item is NewMailItem => item !== null)
|
||||
// 전체 수가 이상하면 받은 메일 수로 센다
|
||||
const count = Math.max(isCount(body.count) ? body.count : 0, messages.length)
|
||||
return count > 0 ? { type: 'new-mail', count, messages } : null
|
||||
}
|
||||
|
||||
/** ping 은 모양이 틀려도 살아 있다는 신호로 본다 — 끊겼다는 것은 upstream: false 로만 안다 */
|
||||
function pingOf(data: string): MailServerEvent {
|
||||
return { type: 'ping', upstream: parseObject(data)?.upstream !== false }
|
||||
}
|
||||
|
||||
function endOf(data: string): MailServerEvent {
|
||||
const reason = parseObject(data)?.reason
|
||||
return { type: 'end', reason: typeof reason === 'string' ? reason : '' }
|
||||
}
|
||||
|
||||
export function parseMailEvent(event: SseEvent): MailServerEvent | null {
|
||||
switch (event.event) {
|
||||
case 'state':
|
||||
return stateOf(event.data)
|
||||
case 'new-mail':
|
||||
return newMailOf(event.data)
|
||||
case 'ping':
|
||||
return pingOf(event.data)
|
||||
case 'end':
|
||||
return endOf(event.data)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
mailEventsLockName,
|
||||
packRealtimeMessage,
|
||||
realtimeLinkText,
|
||||
reconnectDelayMs,
|
||||
unpackRealtimeMessage,
|
||||
} from './mail-realtime'
|
||||
|
||||
// 실시간 연결의 규칙 (계약 2절 "실시간 알림" — 클라이언트)
|
||||
|
||||
describe('realtimeLinkText (설정 › 알림의 실시간 연결 줄)', () => {
|
||||
it('연결됨은 초록 점과 "연결되어 있습니다", 끊김 · 지원 안 함은 "연결할 수 없어 1분마다 확인합니다."', () => {
|
||||
expect(realtimeLinkText('live')).toMatchObject({
|
||||
text: '연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.',
|
||||
notice: null,
|
||||
live: true,
|
||||
})
|
||||
expect(realtimeLinkText('down')).toEqual({
|
||||
text: '연결할 수 없어 1분마다 확인합니다.',
|
||||
notice: '연결될 때까지 바탕화면 알림은 오지 않습니다.',
|
||||
hint: '저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
|
||||
live: false,
|
||||
})
|
||||
expect(realtimeLinkText('unsupported')).toEqual({
|
||||
text: '연결할 수 없어 1분마다 확인합니다.',
|
||||
notice: null,
|
||||
hint: '이 브라우저는 실시간 연결을 지원하지 않습니다.',
|
||||
live: false,
|
||||
})
|
||||
expect(realtimeLinkText('connecting')).toMatchObject({ text: '연결하는 중입니다…', notice: null, live: false })
|
||||
expect(realtimeLinkText('off')).toMatchObject({ text: '연결할 수 없어 1분마다 확인합니다.', live: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconnectDelayMs (다시 잇는 간격)', () => {
|
||||
it('2 · 5 · 15 · 30 · 60초, 그 뒤로는 60초에서 멈춘다 (가운데 값이면 흔들림 없음)', () => {
|
||||
expect([0, 1, 2, 3, 4, 5, 9].map((attempt) => reconnectDelayMs(attempt, 0.5))).toEqual([
|
||||
2_000, 5_000, 15_000, 30_000, 60_000, 60_000, 60_000,
|
||||
])
|
||||
})
|
||||
|
||||
it('±20% 흔들림 — 0 이면 80%, 1 이면 120% (범위 밖의 값은 끝에 맞춘다)', () => {
|
||||
expect(reconnectDelayMs(0, 0)).toBe(1_600)
|
||||
expect(reconnectDelayMs(0, 1)).toBe(2_400)
|
||||
expect(reconnectDelayMs(4, 0)).toBe(48_000)
|
||||
expect(reconnectDelayMs(4, 1)).toBe(72_000)
|
||||
expect(reconnectDelayMs(0, -3)).toBe(1_600)
|
||||
expect(reconnectDelayMs(0, 7)).toBe(2_400)
|
||||
expect(reconnectDelayMs(-1, 0.5)).toBe(2_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mailEventsLockName (연결을 맡을 탭의 잠금)', () => {
|
||||
it('사용자 주소를 붙인다 — 대소문자 · 앞뒤 빈칸은 같은 사용자', () => {
|
||||
expect(mailEventsLockName('ara.kim@arasystem.it.kr')).toBe('arasystem.mail-events:ara.kim@arasystem.it.kr')
|
||||
expect(mailEventsLockName(' Ara.Kim@ArASystem.it.kr ')).toBe(mailEventsLockName('ara.kim@arasystem.it.kr'))
|
||||
expect(mailEventsLockName('doyun.lee@arasystem.it.kr')).not.toBe(mailEventsLockName('ara.kim@arasystem.it.kr'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('탭끼리 주고받는 메시지', () => {
|
||||
const EMAIL = 'ara.kim@arasystem.it.kr'
|
||||
|
||||
it('싸서 보낸 메시지를 같은 사용자는 그대로 읽는다', () => {
|
||||
const messages = [
|
||||
{ type: 'ping', upstream: false },
|
||||
{ type: 'state', state: 's2', inboxUnread: 4 },
|
||||
{ type: 'state', state: 's3', inboxUnread: null },
|
||||
{ type: 'baseline', state: 's3', inboxUnread: 1 },
|
||||
{ type: 'down' },
|
||||
{ type: 'hello' },
|
||||
{ type: 'visibility-query', id: 'q1' },
|
||||
{ type: 'visibility-reply', id: 'q1' },
|
||||
{ type: 'attended' },
|
||||
] as const
|
||||
|
||||
for (const message of messages) {
|
||||
expect(unpackRealtimeMessage(packRealtimeMessage(EMAIL, message), EMAIL)).toEqual(message)
|
||||
}
|
||||
expect(unpackRealtimeMessage(packRealtimeMessage('Ara.Kim@arasystem.it.kr', { type: 'hello' }), EMAIL)).toEqual({
|
||||
type: 'hello',
|
||||
})
|
||||
})
|
||||
|
||||
it('다른 계정 · 다른 앱 · 다른 판 · 모양이 틀린 메시지는 버린다', () => {
|
||||
const packed = packRealtimeMessage(EMAIL, { type: 'state', state: 's1', inboxUnread: 1 })
|
||||
|
||||
expect(unpackRealtimeMessage(packed, 'doyun.lee@arasystem.it.kr')).toBeNull()
|
||||
expect(unpackRealtimeMessage({ ...packed, app: 'arasystem.auth' }, EMAIL)).toBeNull()
|
||||
expect(unpackRealtimeMessage({ ...packed, v: 2 }, EMAIL)).toBeNull()
|
||||
expect(unpackRealtimeMessage({ type: 'logout' }, EMAIL)).toBeNull()
|
||||
expect(unpackRealtimeMessage('ping', EMAIL)).toBeNull()
|
||||
expect(unpackRealtimeMessage({ ...packed, message: { type: 'state', state: '' } }, EMAIL)).toBeNull()
|
||||
expect(unpackRealtimeMessage({ ...packed, message: { type: 'ping' } }, EMAIL)).toBeNull()
|
||||
expect(unpackRealtimeMessage({ ...packed, message: { type: 'visibility-reply' } }, EMAIL)).toBeNull()
|
||||
expect(unpackRealtimeMessage({ ...packed, message: { type: 'baseline' } }, EMAIL)).toBeNull()
|
||||
expect(unpackRealtimeMessage({ ...packed, message: { type: 'new-mail' } }, EMAIL)).toBeNull()
|
||||
})
|
||||
|
||||
it('state 의 안 읽은 수가 이상하면 null 로 읽는다', () => {
|
||||
const packed = packRealtimeMessage(EMAIL, { type: 'state', state: 's1', inboxUnread: 1 })
|
||||
|
||||
expect(unpackRealtimeMessage({ ...packed, message: { type: 'state', state: 's1', inboxUnread: -2 } }, EMAIL)).toEqual({
|
||||
type: 'state',
|
||||
state: 's1',
|
||||
inboxUnread: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
// 실시간 연결의 규칙 — 다시 잇는 간격 · 잠금 이름 · 탭끼리 주고받는 메시지 (순수 함수만 둔다)
|
||||
// 계약 2절 "실시간 알림" 의 클라이언트 규칙: 브라우저마다 연결 하나(Web Locks), 소식은 BroadcastChannel 로 다른 탭에 나눈다.
|
||||
// 연결은 composables/useMailRealtime.ts 가 한다.
|
||||
|
||||
import {
|
||||
MAIL_EVENTS_LOCK_PREFIX,
|
||||
MAIL_EVENTS_RECONNECT_DELAYS_MS,
|
||||
MAIL_EVENTS_RECONNECT_JITTER,
|
||||
MAIL_NOTIFY_TEXT,
|
||||
} from '@/shared/constants/mail-notify'
|
||||
import { normalizeEmail } from '@/shared/utils/mail-address'
|
||||
|
||||
/**
|
||||
* 이 탭이 보는 실시간 연결 상태
|
||||
* - off: 로그인하지 않았다 · unsupported: 이 브라우저는 실시간을 쓰지 않는다(1분 확인만)
|
||||
* - connecting: 처음 잇는 중 · live: 75초 안에 ping · state 가 왔고 서버 · 엔진 사이도 이어져 있다 · down: 그 밖(1분 확인으로 돌아간다)
|
||||
*/
|
||||
export type MailRealtimeStatus = 'off' | 'unsupported' | 'connecting' | 'live' | 'down'
|
||||
|
||||
/** 설정 › 알림의 "실시간 연결" 줄 */
|
||||
export interface RealtimeLinkText {
|
||||
/** 상태 줄 (점 옆) */
|
||||
text: string
|
||||
/** 상태 줄 바로 아래의 알림 — 끊긴 동안 "연결될 때까지 바탕화면 알림은 오지 않습니다." (없으면 null) */
|
||||
notice: string | null
|
||||
/** 맨 아래 안내 (12.5px) */
|
||||
hint: string
|
||||
/** 초록 점 */
|
||||
live: boolean
|
||||
}
|
||||
|
||||
/** 설정 › 알림의 "실시간 연결" 줄 — 상태 · 끊긴 동안의 알림 · 아래 안내 · 초록 점인지 (디자인 원본 SettingsNotify) */
|
||||
export function realtimeLinkText(status: MailRealtimeStatus): RealtimeLinkText {
|
||||
switch (status) {
|
||||
case 'live':
|
||||
return { text: MAIL_NOTIFY_TEXT.realtimeLive, notice: null, hint: MAIL_NOTIFY_TEXT.realtimeLiveHint, live: true }
|
||||
case 'connecting':
|
||||
return {
|
||||
text: MAIL_NOTIFY_TEXT.realtimeConnecting,
|
||||
notice: null,
|
||||
hint: MAIL_NOTIFY_TEXT.realtimeLiveHint,
|
||||
live: false,
|
||||
}
|
||||
case 'unsupported':
|
||||
return {
|
||||
text: MAIL_NOTIFY_TEXT.realtimeDown,
|
||||
notice: null,
|
||||
hint: MAIL_NOTIFY_TEXT.realtimeUnsupportedHint,
|
||||
live: false,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
text: MAIL_NOTIFY_TEXT.realtimeDown,
|
||||
notice: MAIL_NOTIFY_TEXT.realtimeDownNotice,
|
||||
hint: MAIL_NOTIFY_TEXT.realtimeDownHint,
|
||||
live: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 끊긴 뒤 다시 잇기까지 — attempt 는 연결이 열린 뒤 이어서 실패한 횟수(0부터). 2 · 5 · 15 · 30 · 60초에 ±20% 흔들림.
|
||||
* random 은 0~1 (Math.random — 테스트는 고정값을 넘긴다)
|
||||
*/
|
||||
export function reconnectDelayMs(attempt: number, random: number): number {
|
||||
const delays = MAIL_EVENTS_RECONNECT_DELAYS_MS
|
||||
const last = delays.length - 1
|
||||
const base = delays[Math.min(Math.max(0, Math.floor(attempt)), last)] ?? delays[last] ?? 0
|
||||
const spread = Math.min(1, Math.max(0, random))
|
||||
const factor = 1 - MAIL_EVENTS_RECONNECT_JITTER + 2 * MAIL_EVENTS_RECONNECT_JITTER * spread
|
||||
return Math.round(base * factor)
|
||||
}
|
||||
|
||||
/** 연결을 맡을 탭을 고르는 잠금 이름 — 사용자마다 따로 (같은 브라우저에서 계정을 바꿔도 섞이지 않게) */
|
||||
export function mailEventsLockName(email: string): string {
|
||||
return `${MAIL_EVENTS_LOCK_PREFIX}${normalizeEmail(email)}`
|
||||
}
|
||||
|
||||
// ── 탭끼리 주고받는 메시지 ──────────────────────────────────
|
||||
// 연결한 탭 → 모든 탭: ping · state · down (모든 탭이 같은 규칙으로 연결 상태를 정하고 바뀜을 맞춘다)
|
||||
// 잠금을 막 받은 탭도 연결하기 전에 down 을 한 번 보낸다 — 앞 탭이 stop 없이 닫혔어도(탭 닫기 · 새로 고침 · 폐기) 다른 탭이 알게
|
||||
// 새로 연 탭 → 연결한 탭: hello (지금 상태를 다시 보내 달라)
|
||||
// 연결한 탭 → baseline: 끊긴 채일 때의 마지막 state — 살아 있다는 신호로 세지 않고 기준값으로만 쓴다
|
||||
// 연결한 탭 → 모든 탭: visibility-query (사용자가 보고 있는 탭이 있는가 — 바탕화면 알림 전에) / 보고 있는 탭 → visibility-reply
|
||||
// 사용자가 보게 된 탭 → 연결한 탭: attended (띄운 바탕화면 알림을 닫는다)
|
||||
|
||||
export type RealtimeMessage =
|
||||
| { type: 'ping'; upstream: boolean }
|
||||
| { type: 'state'; state: string; inboxUnread: number | null }
|
||||
| { type: 'baseline'; state: string; inboxUnread: number | null }
|
||||
| { type: 'down' }
|
||||
| { type: 'hello' }
|
||||
| { type: 'visibility-query'; id: string }
|
||||
| { type: 'visibility-reply'; id: string }
|
||||
| { type: 'attended' }
|
||||
|
||||
const MESSAGE_APP = 'arasystem.mail-events'
|
||||
/** 메시지 모양을 바꾸면 올린다 — 배포 전에 연 탭(옛 판)의 메시지를 새 판이 잘못 읽지 않게 */
|
||||
const MESSAGE_VERSION = 1
|
||||
|
||||
/** 채널에 싣는 모양 — app · v 로 이 앱의 메시지인지, email 로 같은 사용자인지 본다 */
|
||||
interface RealtimeEnvelope {
|
||||
app: typeof MESSAGE_APP
|
||||
v: typeof MESSAGE_VERSION
|
||||
email: string
|
||||
message: RealtimeMessage
|
||||
}
|
||||
|
||||
export function packRealtimeMessage(email: string, message: RealtimeMessage): RealtimeEnvelope {
|
||||
return { app: MESSAGE_APP, v: MESSAGE_VERSION, email: normalizeEmail(email), message }
|
||||
}
|
||||
|
||||
type Loose = Record<string, unknown>
|
||||
|
||||
function isLoose(value: unknown): value is Loose {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function messageOf(value: unknown): RealtimeMessage | null {
|
||||
if (!isLoose(value)) return null
|
||||
switch (value.type) {
|
||||
case 'ping':
|
||||
return typeof value.upstream === 'boolean' ? { type: 'ping', upstream: value.upstream } : null
|
||||
case 'state':
|
||||
case 'baseline': {
|
||||
if (typeof value.state !== 'string' || value.state === '') return null
|
||||
const unread = value.inboxUnread
|
||||
const inboxUnread = typeof unread === 'number' && Number.isInteger(unread) && unread >= 0 ? unread : null
|
||||
return { type: value.type, state: value.state, inboxUnread }
|
||||
}
|
||||
case 'down':
|
||||
case 'hello':
|
||||
case 'attended':
|
||||
return { type: value.type }
|
||||
case 'visibility-query':
|
||||
case 'visibility-reply':
|
||||
return typeof value.id === 'string' && value.id !== '' ? { type: value.type, id: value.id } : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 채널로 받은 값을 읽는다 — 이 앱 · 이 판 · 같은 사용자의 메시지가 아니면 null (버린다) */
|
||||
export function unpackRealtimeMessage(data: unknown, email: string): RealtimeMessage | null {
|
||||
if (!isLoose(data) || data.app !== MESSAGE_APP || data.v !== MESSAGE_VERSION) return null
|
||||
if (typeof data.email !== 'string' || data.email !== normalizeEmail(email)) return null
|
||||
return messageOf(data.message)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createSseParser, SSE_MAX_FIELD_LENGTH, SseLimitError, type SseEvent } from './sse-parser'
|
||||
|
||||
// SSE 읽기 (계약 2절 "실시간 알림" — text/event-stream) — HTML 표준의 줄 · 필드 규칙
|
||||
|
||||
/** 조각들을 차례로 넣고 나온 이벤트를 모두 모은다 */
|
||||
function parseChunks(chunks: readonly string[]): SseEvent[] {
|
||||
const parser = createSseParser()
|
||||
return chunks.flatMap((chunk) => parser.push(chunk))
|
||||
}
|
||||
|
||||
describe('createSseParser (SSE 읽기)', () => {
|
||||
it('event · data 줄을 빈 줄에서 이벤트 하나로 낸다 — 콜론 뒤 빈칸 하나는 뺀다', () => {
|
||||
const events = parseChunks(['event: state\ndata: {"state":"s1","inboxUnread":3}\n\n'])
|
||||
|
||||
expect(events).toEqual([{ event: 'state', data: '{"state":"s1","inboxUnread":3}', id: null }])
|
||||
})
|
||||
|
||||
it('이름이 없으면 message, data 가 여러 줄이면 줄바꿈으로 잇는다 · 빈칸 하나만 뺀다', () => {
|
||||
const events = parseChunks(['data: 첫 줄\ndata:둘째 줄\ndata: 셋째(빈칸 둘)\n\n'])
|
||||
|
||||
expect(events).toEqual([{ event: 'message', data: '첫 줄\n둘째 줄\n 셋째(빈칸 둘)', id: null }])
|
||||
})
|
||||
|
||||
it('주석 줄(":" 로 시작)은 무시하고 · data 가 없는 이벤트는 내지 않는다', () => {
|
||||
const events = parseChunks([': keep-alive\n\n', 'event: ping\n\n', ':\n', 'event: ping\ndata: {"upstream":true}\n\n'])
|
||||
|
||||
expect(events).toEqual([{ event: 'ping', data: '{"upstream":true}', id: null }])
|
||||
})
|
||||
|
||||
it('빈 data 줄 하나는 빈 글의 이벤트다 · 콜론 없는 줄은 필드 이름만(값은 빈 글)', () => {
|
||||
const events = parseChunks(['data:\n\n', 'event: end\ndata\n\n'])
|
||||
|
||||
expect(events).toEqual([
|
||||
{ event: 'message', data: '', id: null },
|
||||
{ event: 'end', data: '', id: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('CRLF · CR · LF 줄 끝을 모두 받는다', () => {
|
||||
const events = parseChunks(['event: a\r\ndata: 1\r\n\r\n', 'event: b\rdata: 2\r\r', 'event: c\ndata: 3\n\n'])
|
||||
|
||||
expect(events.map((event) => [event.event, event.data])).toEqual([
|
||||
['a', '1'],
|
||||
['b', '2'],
|
||||
['c', '3'],
|
||||
])
|
||||
})
|
||||
|
||||
it('CRLF 가 조각 경계에서 갈라져도 줄 끝 하나로 본다 — 빈 이벤트를 하나 더 만들지 않는다', () => {
|
||||
const events = parseChunks(['event: state\r', '\ndata: x\r', '\n\r', '\n'])
|
||||
|
||||
expect(events).toEqual([{ event: 'state', data: 'x', id: null }])
|
||||
})
|
||||
|
||||
it('CR 뒤에 빈 조각이 와도 다음 조각의 LF 를 그 CR 에 붙이고, 그 뒤의 LF 는 빈 줄로 센다', () => {
|
||||
const events = parseChunks(['data: x\r', '', '\n', '\n'])
|
||||
|
||||
expect(events).toEqual([{ event: 'message', data: 'x', id: null }])
|
||||
})
|
||||
|
||||
it('조각이 줄 · 필드 이름 · 글자 가운데에서 끊겨도 된다', () => {
|
||||
const text = 'event: new-mail\ndata: {"count":1,"messages":[{"subject":"견적서"}]}\n\nevent: ping\ndata: {"upstream":false}\n\n'
|
||||
const oneByOne = parseChunks([...text])
|
||||
|
||||
expect(oneByOne).toEqual([
|
||||
{ event: 'new-mail', data: '{"count":1,"messages":[{"subject":"견적서"}]}', id: null },
|
||||
{ event: 'ping', data: '{"upstream":false}', id: null },
|
||||
])
|
||||
expect(parseChunks([text.slice(0, 7), text.slice(7, 30), text.slice(30)])).toEqual(oneByOne)
|
||||
})
|
||||
|
||||
it('빈 줄로 닫히지 않은 이벤트는 아직 내지 않는다 (스트림이 끝나면 버린다)', () => {
|
||||
const parser = createSseParser()
|
||||
|
||||
expect(parser.push('event: state\ndata: {"state":"s1"}\n')).toEqual([])
|
||||
expect(parser.push('\n')).toEqual([{ event: 'state', data: '{"state":"s1"}', id: null }])
|
||||
expect(parser.push('event: state\ndata: 끝나지 않음')).toEqual([])
|
||||
})
|
||||
|
||||
it('맨 앞의 BOM 은 빼고 · 마지막 id 를 이벤트에 붙인다 (NUL 이 든 id 는 무시)', () => {
|
||||
const events = parseChunks(['id: 7\ndata: a\n\n', 'id: x\0y\ndata: b\n\n'])
|
||||
|
||||
expect(events).toEqual([
|
||||
{ event: 'message', data: 'a', id: '7' },
|
||||
{ event: 'message', data: 'b', id: '7' },
|
||||
])
|
||||
})
|
||||
|
||||
it('줄 끝 없이 상한을 넘게 받으면 SseLimitError — 끝없이 붙잡지 않는다 (기본 1MB, 백엔드와 같다)', () => {
|
||||
expect(SSE_MAX_FIELD_LENGTH).toBe(1_048_576)
|
||||
const parser = createSseParser(10)
|
||||
|
||||
expect(parser.push('data: 12')).toEqual([])
|
||||
expect(() => parser.push('3456')).toThrow(SseLimitError)
|
||||
})
|
||||
|
||||
it('한 이벤트의 data 가 여러 줄로 상한을 넘어도 SseLimitError — 상한까지는 받는다', () => {
|
||||
const parser = createSseParser(10)
|
||||
|
||||
expect(parser.push('data: 12345\ndata: 1234\n\n')).toEqual([{ event: 'message', data: '12345\n1234', id: null }])
|
||||
expect(() => parser.push('data: 12345\ndata: 12345\n')).toThrow(SseLimitError)
|
||||
})
|
||||
|
||||
it('이름은 이벤트마다 새로 정한다 — 앞 이벤트의 이름이 다음 이벤트에 남지 않는다', () => {
|
||||
const events = parseChunks(['event: state\ndata: 1\n\ndata: 2\n\n'])
|
||||
|
||||
expect(events.map((event) => event.event)).toEqual(['state', 'message'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
// SSE(text/event-stream) 읽기 — 순수 함수만 둔다 (HTML 표준 "Interpreting an event stream" 과 같은 규칙)
|
||||
// 받은 글 조각을 차례로 넣으면, 그 조각으로 끝난(빈 줄로 닫힌) 이벤트를 돌려준다. 조각이 줄 · 글자 가운데에서 끊겨도 된다.
|
||||
// - 줄 끝: CRLF · LF · CR — CRLF 가 조각 경계에서 갈라져도(앞 조각 끝 CR, 다음 조각 첫 LF) 줄 끝 하나로 본다
|
||||
// - 줄 모양: "필드:값"(콜론 뒤 빈칸 하나는 뺀다) · ":" 로 시작하면 주석(무시 — 서버의 연결 유지용) · 콜론이 없으면 필드 이름만(값은 빈 글)
|
||||
// - 필드: event(이벤트 이름) · data(여러 줄이면 줄바꿈으로 잇는다) · id — 그 밖(retry 등)은 쓰지 않는다
|
||||
// - 빈 줄에서 이벤트를 낸다. data 줄이 하나도 없으면 내지 않는다(이름만 있는 이벤트). 이름이 없으면 'message'
|
||||
// - 스트림 맨 앞의 BOM(U+FEFF)은 뺀다
|
||||
// 빈 줄로 닫히지 않은 채 스트림이 끝난 이벤트는 버린다(표준과 같다) — 부르는 쪽이 따로 할 일은 없다.
|
||||
// 한 줄(줄 끝을 아직 못 받은 글)이나 한 이벤트의 data 가 상한(maxLength)을 넘으면 SseLimitError — 읽는 쪽이 연결을 실패로 끊는다
|
||||
// (상대가 줄 끝 없이 끝없이 보내도 메모리를 붙잡지 않게 — 백엔드 shared/utils/sse.ts 와 같은 1MB).
|
||||
|
||||
/** 한 줄 · 한 이벤트 data 의 최대 길이 (JS 문자열 길이) — 실시간 알림의 이벤트는 수백 자다 */
|
||||
export const SSE_MAX_FIELD_LENGTH = 1_048_576
|
||||
|
||||
/** 한 줄이나 한 이벤트가 상한을 넘었다 */
|
||||
export class SseLimitError extends Error {
|
||||
constructor() {
|
||||
super('SSE 줄 · 이벤트가 상한을 넘었다')
|
||||
this.name = 'SseLimitError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface SseEvent {
|
||||
/** 이벤트 이름 — event 줄이 없으면 'message' */
|
||||
event: string
|
||||
/** data 줄들을 줄바꿈(\n)으로 이은 것 */
|
||||
data: string
|
||||
/** 마지막으로 받은 id — 없으면 null (이 앱은 쓰지 않는다) */
|
||||
id: string | null
|
||||
}
|
||||
|
||||
export interface SseParser {
|
||||
/** 받은 글 조각을 넣는다 — 이 조각으로 끝난 이벤트를 받은 차례대로 돌려준다 */
|
||||
push: (chunk: string) => SseEvent[]
|
||||
}
|
||||
|
||||
const DEFAULT_EVENT_NAME = 'message'
|
||||
const BYTE_ORDER_MARK = 0xfeff
|
||||
const LF = '\n'
|
||||
const CR = '\r'
|
||||
|
||||
export function createSseParser(maxLength = SSE_MAX_FIELD_LENGTH): SseParser {
|
||||
/** 아직 줄 끝을 받지 못한 글 */
|
||||
let buffer = ''
|
||||
/** 앞 조각이 CR 로 끝났다 — 다음 조각의 첫 LF 는 그 CR 과 한 줄 끝(CRLF)이다 */
|
||||
let pendingCr = false
|
||||
let started = false
|
||||
let eventName = ''
|
||||
let dataLines: string[] = []
|
||||
/** 지금 이벤트의 data 길이 (줄바꿈 포함) */
|
||||
let dataLength = 0
|
||||
let lastId: string | null = null
|
||||
|
||||
function dispatch(events: SseEvent[]) {
|
||||
if (dataLines.length > 0) {
|
||||
events.push({ event: eventName || DEFAULT_EVENT_NAME, data: dataLines.join(LF), id: lastId })
|
||||
}
|
||||
eventName = ''
|
||||
dataLines = []
|
||||
dataLength = 0
|
||||
}
|
||||
|
||||
function addData(value: string) {
|
||||
dataLength += value.length + (dataLines.length > 0 ? LF.length : 0)
|
||||
if (dataLength > maxLength) throw new SseLimitError()
|
||||
dataLines.push(value)
|
||||
}
|
||||
|
||||
function processLine(line: string, events: SseEvent[]) {
|
||||
if (line === '') {
|
||||
dispatch(events)
|
||||
return
|
||||
}
|
||||
if (line.startsWith(':')) return
|
||||
|
||||
const colon = line.indexOf(':')
|
||||
const field = colon === -1 ? line : line.slice(0, colon)
|
||||
let value = colon === -1 ? '' : line.slice(colon + 1)
|
||||
if (value.startsWith(' ')) value = value.slice(1)
|
||||
|
||||
if (field === 'event') eventName = value
|
||||
else if (field === 'data') addData(value)
|
||||
// 표준: NUL 이 든 id 는 무시한다
|
||||
else if (field === 'id' && !value.includes('\0')) lastId = value
|
||||
}
|
||||
|
||||
function push(chunk: string): SseEvent[] {
|
||||
let text = chunk
|
||||
if (!started && text.length > 0) {
|
||||
started = true
|
||||
if (text.charCodeAt(0) === BYTE_ORDER_MARK) text = text.slice(1)
|
||||
}
|
||||
if (pendingCr && text.length > 0) {
|
||||
if (text.startsWith(LF)) text = text.slice(1)
|
||||
pendingCr = false
|
||||
}
|
||||
|
||||
buffer += text
|
||||
const events: SseEvent[] = []
|
||||
let lineStart = 0
|
||||
for (let index = 0; index < buffer.length; index += 1) {
|
||||
const char = buffer[index]
|
||||
if (char !== LF && char !== CR) continue
|
||||
processLine(buffer.slice(lineStart, index), events)
|
||||
if (char === CR) {
|
||||
if (index + 1 < buffer.length) {
|
||||
if (buffer[index + 1] === LF) index += 1
|
||||
} else {
|
||||
pendingCr = true
|
||||
}
|
||||
}
|
||||
lineStart = index + 1
|
||||
}
|
||||
buffer = buffer.slice(lineStart)
|
||||
if (buffer.length > maxLength) throw new SseLimitError()
|
||||
return events
|
||||
}
|
||||
|
||||
return { push }
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import { FakeNotification } from '@/test-utils/notification'
|
||||
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
|
||||
import { useAuthStore } from './auth.store'
|
||||
import { mailNotifyStorageKey, useMailNotifyStore } from './mail-notify.store'
|
||||
|
||||
// 바탕화면 알림 설정 — 브라우저 · 사용자마다 localStorage (디자인 README "5단계 › 설정 › 메일 › 알림" · mail-view.store 와 같은 방식)
|
||||
|
||||
const ARA = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const DOYUN = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
|
||||
|
||||
describe('mail-notify.store (바탕화면 알림 설정)', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
installFakeLocks()
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
vi.stubGlobal('Notification', FakeNotification)
|
||||
FakeNotification.reset('default')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
uninstallFakeLocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function loggedInStore(user = ARA) {
|
||||
useAuthStore().markAuthenticated(user)
|
||||
return useMailNotifyStore()
|
||||
}
|
||||
|
||||
it('기본값은 꺼짐 · 메일 제목 보이기 · 권하기 닫지 않음 — 브라우저가 묻기 전이면 권하기 줄이 보인다', () => {
|
||||
const store = loggedInStore()
|
||||
|
||||
expect(store.settings).toEqual({ enabled: false, showSubject: true, offerDismissed: false })
|
||||
expect(store.permission).toBe('default')
|
||||
expect(store.canNotify).toBe(false)
|
||||
expect(store.offerVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('켜기 — 브라우저가 묻고, 허용하면 켠 것을 사용자 주소가 든 키로 저장한다', async () => {
|
||||
const store = loggedInStore()
|
||||
FakeNotification.nextAnswer = 'granted'
|
||||
|
||||
await expect(store.turnOn()).resolves.toBe('granted')
|
||||
|
||||
expect(FakeNotification.requestCount).toBe(1)
|
||||
expect(store.settings.enabled).toBe(true)
|
||||
expect(store.canNotify).toBe(true)
|
||||
expect(store.offerVisible).toBe(false)
|
||||
expect(JSON.parse(window.localStorage.getItem(mailNotifyStorageKey(ARA.email)) ?? '{}')).toMatchObject({ enabled: true })
|
||||
expect(mailNotifyStorageKey('Ara.Kim@ArASystem.test')).toBe(mailNotifyStorageKey(ARA.email))
|
||||
})
|
||||
|
||||
it('켜기 — 막으면 켜지 않고(권하기 줄도 사라진다), 그냥 닫으면 그대로 다시 물을 수 있다', async () => {
|
||||
const store = loggedInStore()
|
||||
|
||||
FakeNotification.nextAnswer = 'default'
|
||||
await expect(store.turnOn()).resolves.toBe('default')
|
||||
expect(store.settings.enabled).toBe(false)
|
||||
expect(store.offerVisible).toBe(true)
|
||||
|
||||
FakeNotification.nextAnswer = 'denied'
|
||||
await expect(store.turnOn()).resolves.toBe('denied')
|
||||
expect(store.settings.enabled).toBe(false)
|
||||
expect(store.permission).toBe('denied')
|
||||
expect(store.offerVisible).toBe(false)
|
||||
|
||||
// 막힌 뒤에는 브라우저가 다시 묻지 않는다 — 묻지 않고 그대로 돌려준다
|
||||
await expect(store.turnOn()).resolves.toBe('denied')
|
||||
expect(FakeNotification.requestCount).toBe(2)
|
||||
})
|
||||
|
||||
it('이미 허락했으면 묻지 않고 켠다 · 끄면 꺼진다(허락은 그대로)', async () => {
|
||||
FakeNotification.reset('granted')
|
||||
const store = loggedInStore()
|
||||
|
||||
await expect(store.turnOn()).resolves.toBe('granted')
|
||||
expect(FakeNotification.requestCount).toBe(0)
|
||||
expect(store.canNotify).toBe(true)
|
||||
|
||||
store.turnOff()
|
||||
expect(store.settings.enabled).toBe(false)
|
||||
expect(store.permission).toBe('granted')
|
||||
expect(store.offerVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('지원하지 않는 브라우저 — 묻지 않고 unsupported, 권하기 줄도 없다', async () => {
|
||||
vi.stubGlobal('Notification', undefined)
|
||||
const store = loggedInStore()
|
||||
|
||||
expect(store.permission).toBe('unsupported')
|
||||
await expect(store.turnOn()).resolves.toBe('unsupported')
|
||||
expect(store.settings.enabled).toBe(false)
|
||||
expect(store.offerVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('권하기 줄을 닫으면 다시 보이지 않는다 (저장한다) · 메일 제목 보이기도 저장한다', () => {
|
||||
const store = loggedInStore()
|
||||
|
||||
store.dismissOffer()
|
||||
store.setShowSubject(false)
|
||||
|
||||
expect(store.offerVisible).toBe(false)
|
||||
expect(JSON.parse(window.localStorage.getItem(mailNotifyStorageKey(ARA.email)) ?? '{}')).toEqual({
|
||||
enabled: false,
|
||||
showSubject: false,
|
||||
offerDismissed: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('로그인한 사용자가 바뀌면 그 사용자의 설정을 다시 읽는다 (로그아웃이면 기본값)', async () => {
|
||||
window.localStorage.setItem(
|
||||
mailNotifyStorageKey(ARA.email),
|
||||
JSON.stringify({ enabled: true, showSubject: false, offerDismissed: true }),
|
||||
)
|
||||
const authStore = useAuthStore()
|
||||
const store = useMailNotifyStore()
|
||||
|
||||
authStore.markAuthenticated(ARA)
|
||||
await nextTick()
|
||||
expect(store.settings).toEqual({ enabled: true, showSubject: false, offerDismissed: true })
|
||||
|
||||
authStore.startSession(DOYUN)
|
||||
await nextTick()
|
||||
expect(store.settings).toEqual({ enabled: false, showSubject: true, offerDismissed: false })
|
||||
|
||||
authStore.clearAuth()
|
||||
await nextTick()
|
||||
expect(store.settings.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('다른 탭이 같은 사용자의 설정을 바꾸면 따라간다 (storage 이벤트) — 다른 키는 무시한다', () => {
|
||||
const store = loggedInStore()
|
||||
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: mailNotifyStorageKey(DOYUN.email),
|
||||
newValue: JSON.stringify({ enabled: true }),
|
||||
}),
|
||||
)
|
||||
expect(store.settings.enabled).toBe(false)
|
||||
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: mailNotifyStorageKey(ARA.email),
|
||||
newValue: JSON.stringify({ enabled: true, showSubject: true, offerDismissed: true }),
|
||||
}),
|
||||
)
|
||||
expect(store.settings).toEqual({ enabled: true, showSubject: true, offerDismissed: true })
|
||||
})
|
||||
|
||||
it('저장소를 쓸 수 없어도(사생활 보호 모드 등) 기본값으로 동작하고, 바꾼 값은 이번 화면에서 유지한다', async () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('차단됨')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('차단됨')
|
||||
})
|
||||
const store = loggedInStore()
|
||||
await nextTick()
|
||||
|
||||
expect(store.settings.enabled).toBe(false)
|
||||
expect(() => store.dismissOffer()).not.toThrow()
|
||||
expect(store.settings.offerDismissed).toBe(true)
|
||||
})
|
||||
|
||||
it('브라우저의 허락은 저장하지 않고 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다', () => {
|
||||
const store = loggedInStore()
|
||||
|
||||
FakeNotification.permission = 'denied'
|
||||
expect(store.permission).toBe('default')
|
||||
store.refreshPermission()
|
||||
expect(store.permission).toBe('denied')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { currentNotificationPermission, requestNotificationPermission } from '@/composables/useDesktopNotification'
|
||||
import { MAIL_NOTIFY_STORAGE_PREFIX } from '@/shared/constants/mail-notify'
|
||||
import {
|
||||
DEFAULT_MAIL_NOTIFY_SETTINGS,
|
||||
parseMailNotifySettings,
|
||||
shouldOfferDesktopNotify,
|
||||
type DesktopNotifyPermission,
|
||||
type MailNotifySettings,
|
||||
} from '@/shared/utils/desktop-notify'
|
||||
import { normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 바탕화면 알림 설정 — 켜기(기본 꺼짐) · 메일 제목도 보이기(기본 켬) · 목록 위 권하기를 닫았는지 (디자인 README "5단계")
|
||||
// 서버 설정이 아니라 브라우저 · 사용자마다 localStorage 에 둔다 (계약 2절 "실시간 알림" — mail-view.store 와 같은 방식:
|
||||
// 키에 사용자 주소, 저장소를 못 쓰면 기본값). 다른 탭에서 바꾸면(storage 이벤트) 이 탭도 따라간다 —
|
||||
// 알림을 보내는 연결 탭이 그 값을 바로 쓰고, 목록 위의 권하기 줄도 다른 탭에서 켜거나 닫으면 사라진다.
|
||||
// 브라우저의 허락(Notification.permission)은 저장하지 않고 그때그때 읽는다 (permission — 설정 화면 · 권하기 줄이 본다).
|
||||
// 띄우려 했더니 만들 수 없는 브라우저였으면(휴대폰 Chrome 등 — showDesktopNotification 의 'unsupported') markUnsupported 로
|
||||
// 이 화면을 연 동안은 "지원하지 않음"으로 둔다 (설정 화면의 안내가 그렇게 바뀐다).
|
||||
|
||||
export function mailNotifyStorageKey(email: string): string {
|
||||
return `${MAIL_NOTIFY_STORAGE_PREFIX}${normalizeEmail(email)}`
|
||||
}
|
||||
|
||||
// 사생활 보호 모드 · 저장소 차단 · 용량 초과 등으로 localStorage 가 실패해도 기본값으로 동작해야 한다
|
||||
function readStored(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: string) {
|
||||
try {
|
||||
window.localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// 기억하지 못해도 이번 화면에서는 바꾼 값으로 계속 쓴다
|
||||
}
|
||||
}
|
||||
|
||||
export const useMailNotifyStore = defineStore('mail-notify', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const settings = ref<MailNotifySettings>({ ...DEFAULT_MAIL_NOTIFY_SETTINGS })
|
||||
/** 브라우저의 알림 허락 — 지원하지 않으면 'unsupported' */
|
||||
const permission = ref<DesktopNotifyPermission>(currentNotificationPermission())
|
||||
/** 허락을 묻는 중 — 칸 · 단추를 다시 누르지 못하게 */
|
||||
const requesting = ref<boolean>(false)
|
||||
/** 알림을 만들 수 없는 브라우저로 드러났다 — 화면을 새로 열 때까지 */
|
||||
let constructorUnsupported = false
|
||||
let storageKey: string | null = null
|
||||
|
||||
/** 알림을 보낼 수 있다 — 설정을 켰고 브라우저가 허락했다 */
|
||||
const canNotify = computed<boolean>(() => settings.value.enabled && permission.value === 'granted')
|
||||
/** 목록 위의 권하기 줄 */
|
||||
const offerVisible = computed<boolean>(() =>
|
||||
shouldOfferDesktopNotify({ permission: permission.value, settings: settings.value }),
|
||||
)
|
||||
|
||||
function loadFor(email: string | null) {
|
||||
storageKey = email ? mailNotifyStorageKey(email) : null
|
||||
settings.value = storageKey ? parseMailNotifySettings(readStored(storageKey)) : { ...DEFAULT_MAIL_NOTIFY_SETTINGS }
|
||||
refreshPermission()
|
||||
}
|
||||
|
||||
function update(patch: Partial<MailNotifySettings>) {
|
||||
settings.value = { ...settings.value, ...patch }
|
||||
if (storageKey) writeStored(storageKey, JSON.stringify(settings.value))
|
||||
}
|
||||
|
||||
/** 브라우저의 허락을 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다 */
|
||||
function refreshPermission() {
|
||||
permission.value = constructorUnsupported ? 'unsupported' : currentNotificationPermission()
|
||||
}
|
||||
|
||||
/** 띄우려 했더니 만들 수 없는 브라우저였다 — "지원하지 않음"으로 바꾼다 (설정은 그대로 둔다) */
|
||||
function markUnsupported() {
|
||||
constructorUnsupported = true
|
||||
permission.value = 'unsupported'
|
||||
}
|
||||
|
||||
/**
|
||||
* 켠다 — 브라우저가 아직 묻지 않았으면 허락을 묻는다(누른 처리 안에서 바로 부른다). 허락하면 켜고 'granted'.
|
||||
* 막았으면 'denied', 그냥 닫았으면 'default' — 둘 다 켜지 않는다
|
||||
*/
|
||||
async function turnOn(): Promise<DesktopNotifyPermission> {
|
||||
if (requesting.value) return permission.value
|
||||
refreshPermission()
|
||||
if (permission.value === 'granted') {
|
||||
update({ enabled: true })
|
||||
return 'granted'
|
||||
}
|
||||
if (permission.value !== 'default') return permission.value
|
||||
requesting.value = true
|
||||
try {
|
||||
const result = await requestNotificationPermission()
|
||||
permission.value = result
|
||||
if (result === 'granted') update({ enabled: true })
|
||||
return result
|
||||
} finally {
|
||||
requesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function turnOff() {
|
||||
update({ enabled: false })
|
||||
}
|
||||
|
||||
function setShowSubject(showSubject: boolean) {
|
||||
update({ showSubject })
|
||||
}
|
||||
|
||||
/** 목록 위의 권하기 줄을 닫는다 — 다시 보이지 않는다 */
|
||||
function dismissOffer() {
|
||||
update({ offerDismissed: true })
|
||||
}
|
||||
|
||||
// 다른 탭이 같은 사용자의 설정을 바꾸면 따라간다 (storage 이벤트는 바꾼 탭이 아닌 탭에만 온다)
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (storageKey && event.key === storageKey) settings.value = parseMailNotifySettings(event.newValue)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') window.addEventListener('storage', onStorage)
|
||||
|
||||
// 사이트 설정에서 허락을 바꾸면 바로 따라간다 — Permissions API 가 있는 브라우저만 (없으면 화면이 열릴 때 다시 읽는다)
|
||||
try {
|
||||
void navigator.permissions?.query({ name: 'notifications' }).then(
|
||||
(status) => {
|
||||
status.addEventListener('change', refreshPermission)
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
} catch {
|
||||
// 묻지 못하는 브라우저 — 위와 같다
|
||||
}
|
||||
|
||||
// 로그인한 사용자가 바뀌면 그 사용자의 설정을 다시 읽는다 (로그아웃이면 기본값)
|
||||
watch(() => authStore.user?.email ?? null, loadFor, { immediate: true })
|
||||
|
||||
return {
|
||||
settings,
|
||||
permission,
|
||||
requesting,
|
||||
canNotify,
|
||||
offerVisible,
|
||||
refreshPermission,
|
||||
markUnsupported,
|
||||
turnOn,
|
||||
turnOff,
|
||||
setShowSubject,
|
||||
dismissOffer,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { MailRealtimeStatus } from '@/shared/utils/mail-realtime'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
export type { MailRealtimeStatus } from '@/shared/utils/mail-realtime'
|
||||
|
||||
// 실시간 새 메일 — 이 탭이 보는 연결 상태와 "바뀜" (계약 2절 "실시간 알림" · "새 메일 확인")
|
||||
// 연결은 앱 전체에서 로그인한 동안 돈다(composables/useMailRealtime.ts — App.vue). 연결은 브라우저마다 한 탭만 하고,
|
||||
// 그 소식(state · ping)은 모든 탭이 같은 규칙으로 받는다. 이 스토어는 그 결과만 둔다:
|
||||
// - status: 연결 상태 — 메일 화면은 live 인 동안 1분 확인을 멈추고, 설정 › 알림이 보여 준다
|
||||
// - 바뀜: 1분 확인 · 실시간 소식이 같은 기준(마지막으로 본 state)으로 비교한다. 바뀌었으면 메일 화면이 맞출 일(pendingSync)을 남긴다 —
|
||||
// 메일 화면이 보이면 바로, 숨었거나 다른 화면이면 다시 보일 때 · 메일 화면으로 돌아올 때 맞춘다 (useMailStatePolling)
|
||||
|
||||
export const useMailRealtimeStore = defineStore('mail-realtime', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const status = ref<MailRealtimeStatus>('off')
|
||||
/** 실시간 연결이 살아 있다 — 1분 확인을 멈춘다 */
|
||||
const isLive = computed<boolean>(() => status.value === 'live')
|
||||
/** 메일 화면이 아직 맞추지 않은 바뀜이 있다 */
|
||||
const pendingSync = ref<boolean>(false)
|
||||
/** 마지막 바뀜과 함께 온 받은편지함의 안 읽은 수 — 모르면 null (1분 확인은 이 수를 주지 않는다) */
|
||||
const inboxUnread = ref<number | null>(null)
|
||||
/** 바뀜을 볼 때마다 오른다 — 메일 화면이 이 값을 지켜본다 */
|
||||
const changeSeq = ref<number>(0)
|
||||
|
||||
/** 마지막으로 본 state — 화면에 그리지 않으므로 반응형이 아니다 */
|
||||
let baseline: string | null = null
|
||||
|
||||
function setStatus(next: MailRealtimeStatus) {
|
||||
status.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* state 를 본다 — 처음 본 값은 기준으로만 쓰고, 지난 값과 다르면 바뀜으로 남긴다.
|
||||
* @returns 바뀌었는지
|
||||
*/
|
||||
function observeState(state: string, unread: number | null = null): boolean {
|
||||
const changed = baseline !== null && state !== baseline
|
||||
baseline = state
|
||||
if (!changed) return false
|
||||
inboxUnread.value = unread
|
||||
pendingSync.value = true
|
||||
changeSeq.value += 1
|
||||
return true
|
||||
}
|
||||
|
||||
/** 메일 화면이 맞추기 시작한다 — 남은 바뀜이 있었으면 true (한 번만) */
|
||||
function takePendingSync(): boolean {
|
||||
if (!pendingSync.value) return false
|
||||
pendingSync.value = false
|
||||
return true
|
||||
}
|
||||
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 로그인) 지난 기준을 버린다 — 다른 계정의 state 와 비교하지 않게.
|
||||
// 바로(sync) 버린다 — 새 세션의 첫 state 가 옛 기준과 비교되지 않게
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
baseline = null
|
||||
pendingSync.value = false
|
||||
inboxUnread.value = null
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
return { status, isLive, pendingSync, inboxUnread, changeSeq, setStatus, observeState, takePendingSync }
|
||||
})
|
||||
@@ -105,6 +105,7 @@ export const useMailStore = defineStore('mail', () => {
|
||||
loadQuota: mailboxState.loadQuota,
|
||||
ensureQuota: mailboxState.ensureQuota,
|
||||
syncCounts: mailboxState.syncCounts,
|
||||
setInboxUnread: mailboxState.setInboxUnread,
|
||||
// 보이는 목록
|
||||
listSource: list.listSource,
|
||||
currentMailboxId: list.currentMailboxId,
|
||||
|
||||
@@ -161,6 +161,15 @@ export function createMailboxesState(mailApi: MailApi) {
|
||||
mailboxes.value = mailboxes.value.map((mailbox) => (mailbox.id === id ? { ...mailbox, ...counts } : mailbox))
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은편지함의 안 읽은 수만 바로 고친다 — 숨은 탭이 실시간 소식(inboxUnread)으로 탭 제목 "(N) " 을 맞출 때 (계약 2절 "실시간 알림").
|
||||
* 다시 보이면 메일함 목록을 서버 기준으로 다시 받는다. 메일함을 아직 받지 않았으면 할 일이 없다
|
||||
*/
|
||||
function setInboxUnread(count: number) {
|
||||
const inbox = mailboxByRole('inbox')
|
||||
if (inbox && inbox.unreadCount !== count) setCounts(inbox.id, { unreadCount: count })
|
||||
}
|
||||
|
||||
function reset() {
|
||||
mailboxSeq += 1
|
||||
quotaSeq += 1
|
||||
@@ -192,6 +201,7 @@ export function createMailboxesState(mailApi: MailApi) {
|
||||
upsertMailbox,
|
||||
removeMailbox,
|
||||
setCounts,
|
||||
setInboxUnread,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
--color-brand-strong: #0a5f67; /* 강조 글자 · 눌린 상태 */
|
||||
--color-brand-subtle: #e3f2f3; /* 선택한 행 · 현재 메일함 · 아바타 */
|
||||
--color-brand-tint: #f3f8f8; /* 여러 통 처리 도구 줄 · 새 메일 알림 줄 바탕 (디자인 원본 BulkSelect · ListStates) */
|
||||
--color-brand-tint-border: #e3eeef; /* 목록 위 알림 권하기 줄의 아래 선 (디자인 원본 MailRealtime) */
|
||||
--color-on-brand: #ffffff;
|
||||
--color-on-brand-text: #e6eff0;
|
||||
--color-on-brand-muted: #a3c2c6;
|
||||
@@ -80,6 +81,7 @@
|
||||
--color-warning-surface: #fff7e6;
|
||||
--color-warning-text: #7a5200; /* 임시 저장 "다른 곳에서 바뀐 저장본" 안내 */
|
||||
--color-success-text: #067647; /* 서명 "저장했습니다" (디자인 원본 Settings) */
|
||||
--color-status-live: #12b76a; /* 실시간 연결 "연결되어 있습니다" 점 (디자인 원본 SettingsNotify) */
|
||||
--color-star: #b7791f;
|
||||
|
||||
/* ── 간격 (이름의 숫자 = px) ─────────────── */
|
||||
@@ -153,6 +155,12 @@
|
||||
* 메일함 행의 ⋯ 단추 32×40(행 높이 --size-row-min — 목록 표의 별 칸과 같은 폭) · ⋯ 메뉴 항목 40(--size-editor-menu-item)
|
||||
*/
|
||||
--size-mailbox-more: 2rem; /* 32px — 메일함 행의 ⋯ 단추 폭 · 밀도 예외 */
|
||||
/*
|
||||
* 5단계(실시간 새 메일 · 바탕화면 알림) 밀도 예외 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "5단계 › 목록 위에서 한 번 권하기", 사용자 확정 캔버스).
|
||||
* 목록 위 권하기 줄 높이 40(--size-notice-bar — 새 메일 알림 줄과 같다) · 줄 안의 [알림 켜기] 높이 36 · [✕ 닫기] 36×36
|
||||
*/
|
||||
--size-notice-action: 2.25rem; /* 36px — 알림 권하기 줄 안의 단추 · 밀도 예외 */
|
||||
--size-status-dot: 0.5rem; /* 8px — 설정 › 알림의 실시간 연결 점 */
|
||||
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */
|
||||
--form-max-width: 26.25rem; /* 420px — 비밀번호 칸 (디자인 원본 SettingsAccount) */
|
||||
--form-wide-max-width: 32.5rem; /* 520px — 자동 응답 제목 칸 (디자인 원본 SettingsVacation) */
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// 단위 테스트 전용 — SSE(text/event-stream) 를 돌려주는 가짜 fetch. 연결마다 테스트가 이벤트를 보내고 · 닫고 · 끊는다.
|
||||
// 응답 차례는 테스트가 정한다(reply) — 정하지 않으면 200 스트림. 클라이언트가 그만두면(AbortController) 스트림이 AbortError 로 끝난다.
|
||||
// 사용: const server = createEventStreamServer(); useMailRealtime({ fetch: server.fetch, … })
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
|
||||
/** 연결 하나의 응답 — 200 스트림 · 다른 상태(본문 JSON) · 네트워크 오류 · 머리글이 오지 않음(그만둘 때까지) */
|
||||
export type EventStreamReply =
|
||||
| { kind: 'stream'; contentType?: string }
|
||||
| { kind: 'status'; status: number; body?: unknown }
|
||||
| { kind: 'network' }
|
||||
| { kind: 'hang' }
|
||||
|
||||
export interface EventStreamConnection {
|
||||
url: string
|
||||
init: RequestInit
|
||||
/** 클라이언트가 그만두었다 */
|
||||
readonly aborted: boolean
|
||||
/** SSE 글을 그대로 보낸다 (조각 시험용) */
|
||||
send: (text: string) => void
|
||||
/** 이벤트 하나 — data 는 JSON 으로 */
|
||||
emit: (event: string, data: unknown) => void
|
||||
/** 서버가 닫는다 (스트림 끝) */
|
||||
close: () => void
|
||||
/** 연결이 끊긴다 (읽기 오류) */
|
||||
fail: () => void
|
||||
}
|
||||
|
||||
export interface EventStreamServer {
|
||||
fetch: (input: string, init?: RequestInit) => Promise<Response>
|
||||
/** 받은 연결 — 스트림 응답만이 아니라 모든 요청 */
|
||||
readonly connections: EventStreamConnection[]
|
||||
/** 다음 요청들의 응답 차례 */
|
||||
reply: (...replies: EventStreamReply[]) => void
|
||||
/** 마지막 연결 */
|
||||
last: () => EventStreamConnection
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException('그만두었다', 'AbortError')
|
||||
}
|
||||
|
||||
export function createEventStreamServer(): EventStreamServer {
|
||||
const connections: EventStreamConnection[] = []
|
||||
const queue: EventStreamReply[] = []
|
||||
|
||||
function open(url: string, init: RequestInit, reply: EventStreamReply): Promise<Response> {
|
||||
let aborted = false
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
||||
let finished = false
|
||||
let rejectPending: ((reason: unknown) => void) | null = null
|
||||
|
||||
const connection: EventStreamConnection = {
|
||||
url,
|
||||
init,
|
||||
get aborted() {
|
||||
return aborted
|
||||
},
|
||||
send: (text) => {
|
||||
if (!finished) controller?.enqueue(encoder.encode(text))
|
||||
},
|
||||
emit: (event, data) => connection.send(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
|
||||
close: () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
controller?.close()
|
||||
},
|
||||
fail: () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
controller?.error(new TypeError('network error'))
|
||||
},
|
||||
}
|
||||
connections.push(connection)
|
||||
|
||||
init.signal?.addEventListener('abort', () => {
|
||||
aborted = true
|
||||
rejectPending?.(abortError())
|
||||
if (!finished) {
|
||||
finished = true
|
||||
controller?.error(abortError())
|
||||
}
|
||||
})
|
||||
|
||||
if (reply.kind === 'network') return Promise.reject(new TypeError('Failed to fetch'))
|
||||
if (reply.kind === 'hang') {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
rejectPending = reject
|
||||
})
|
||||
}
|
||||
if (reply.kind === 'status') {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(reply.body ?? { success: false }), {
|
||||
status: reply.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(streamController) {
|
||||
controller = streamController
|
||||
},
|
||||
})
|
||||
return Promise.resolve(
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': reply.contentType ?? 'text/event-stream; charset=utf-8' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
fetch: (input, init = {}) => open(input, init, queue.shift() ?? { kind: 'stream' }),
|
||||
connections,
|
||||
reply: (...replies) => {
|
||||
queue.push(...replies)
|
||||
},
|
||||
last: () => {
|
||||
const connection = connections.at(-1)
|
||||
if (!connection) throw new Error('아직 연결이 없다')
|
||||
return connection
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// 단위 테스트 전용 — 브라우저 Notification 대역. 띄운 알림을 모아 두고, 허락 묻기의 답을 테스트가 정한다.
|
||||
// 사용: vi.stubGlobal('Notification', FakeNotification) — 테스트마다 FakeNotification.reset() 으로 비운다.
|
||||
// - nextAnswer: 다음 requestPermission 의 답 · holdAnswer(): 답을 미뤄 두고(묻는 창이 떠 있는 동안) 나중에 준다
|
||||
// - constructorThrows: 만들면 예외 — 휴대폰 Chrome 처럼 페이지에서 바로 띄울 수 없는 브라우저
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
|
||||
export class FakeNotification {
|
||||
static permission: NotificationPermission = 'default'
|
||||
/** requestPermission 이 줄 답 — 정하지 않으면 지금 값 */
|
||||
static nextAnswer: NotificationPermission | null = null
|
||||
static requestCount = 0
|
||||
static constructorThrows = false
|
||||
static readonly shown: FakeNotification[] = []
|
||||
/** 다음 허락 묻기의 답을 미룬다 (holdAnswer) */
|
||||
private static holding = false
|
||||
/** 미뤄 둔 답을 주는 함수 */
|
||||
private static held: ((answer: NotificationPermission) => void) | null = null
|
||||
|
||||
readonly title: string
|
||||
readonly options: NotificationOptions & { renotify?: boolean }
|
||||
onclick: ((this: FakeNotification, event: Event) => unknown) | null = null
|
||||
closed = false
|
||||
|
||||
constructor(title: string, options: NotificationOptions & { renotify?: boolean } = {}) {
|
||||
if (FakeNotification.constructorThrows) {
|
||||
throw new TypeError("Failed to construct 'Notification': Illegal constructor.")
|
||||
}
|
||||
if (FakeNotification.permission !== 'granted') throw new TypeError('허락하지 않은 알림')
|
||||
this.title = title
|
||||
this.options = options
|
||||
FakeNotification.shown.push(this)
|
||||
}
|
||||
|
||||
static requestPermission(): Promise<NotificationPermission> {
|
||||
FakeNotification.requestCount += 1
|
||||
if (FakeNotification.held === null && FakeNotification.holding) {
|
||||
FakeNotification.holding = false
|
||||
return new Promise((resolve) => {
|
||||
FakeNotification.held = (answer) => {
|
||||
FakeNotification.held = null
|
||||
FakeNotification.permission = answer
|
||||
resolve(answer)
|
||||
}
|
||||
})
|
||||
}
|
||||
const answer = FakeNotification.nextAnswer ?? FakeNotification.permission
|
||||
FakeNotification.nextAnswer = null
|
||||
FakeNotification.permission = answer
|
||||
return Promise.resolve(answer)
|
||||
}
|
||||
|
||||
/** 다음 허락 묻기의 답을 미룬다 — 돌려준 함수로 답한다 (묻는 창이 떠 있는 동안을 시험한다) */
|
||||
static holdAnswer(): (answer: NotificationPermission) => void {
|
||||
FakeNotification.holding = true
|
||||
return (answer) => FakeNotification.held?.(answer)
|
||||
}
|
||||
|
||||
/** 사용자가 알림을 눌렀다 */
|
||||
click(): void {
|
||||
this.onclick?.call(this, new Event('click'))
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true
|
||||
}
|
||||
|
||||
static reset(permission: NotificationPermission = 'default'): void {
|
||||
FakeNotification.permission = permission
|
||||
FakeNotification.nextAnswer = null
|
||||
FakeNotification.requestCount = 0
|
||||
FakeNotification.constructorThrows = false
|
||||
FakeNotification.holding = false
|
||||
FakeNotification.held = null
|
||||
FakeNotification.shown.length = 0
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ export function createTestRouter(): Router {
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/mail/notify',
|
||||
name: 'settings-mail-notify',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// 단위 테스트 전용 — Web Locks(navigator.locks) 대역. 같은 이름의 잠금은 한 번에 하나만 주고, 나머지는 줄을 세운다.
|
||||
// 표준처럼 잠금은 콜백이 돌려준 Promise 가 끝날 때 풀리고, signal 로 그만두면 기다리던 요청만 AbortError 로 거절한다.
|
||||
// 사용: installFakeLocks() — 끝나면 uninstallFakeLocks(). 여러 "탭"이 같은 navigator.locks 를 쓴다.
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
|
||||
type Callback = (lock: Lock | null) => unknown
|
||||
|
||||
interface Waiter {
|
||||
name: string
|
||||
callback: Callback
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException('잠금 요청을 그만두었다', 'AbortError')
|
||||
}
|
||||
|
||||
export class FakeLockManager {
|
||||
private readonly held = new Map<string, Waiter>()
|
||||
private readonly queues = new Map<string, Waiter[]>()
|
||||
|
||||
request(name: string, ...rest: [Callback] | [LockOptions, Callback]): Promise<unknown> {
|
||||
const [options, callback] = rest.length === 1 ? [{} as LockOptions, rest[0]] : rest
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(abortError())
|
||||
return
|
||||
}
|
||||
const waiter: Waiter = { name, callback, resolve, reject }
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
const queue = this.queues.get(name) ?? []
|
||||
const index = queue.indexOf(waiter)
|
||||
if (index === -1) return
|
||||
queue.splice(index, 1)
|
||||
reject(abortError())
|
||||
})
|
||||
const queue = this.queues.get(name) ?? []
|
||||
queue.push(waiter)
|
||||
this.queues.set(name, queue)
|
||||
this.grant(name)
|
||||
})
|
||||
}
|
||||
|
||||
/** 지금 그 잠금을 가진 요청이 있는지 */
|
||||
isHeld(name: string): boolean {
|
||||
return this.held.has(name)
|
||||
}
|
||||
|
||||
/** 그 잠금을 기다리는 요청 수 */
|
||||
waitingCount(name: string): number {
|
||||
return this.queues.get(name)?.length ?? 0
|
||||
}
|
||||
|
||||
private grant(name: string): void {
|
||||
if (this.held.has(name)) return
|
||||
const next = this.queues.get(name)?.shift()
|
||||
if (!next) return
|
||||
this.held.set(name, next)
|
||||
const lock = { name, mode: 'exclusive' } as Lock
|
||||
void Promise.resolve()
|
||||
.then(() => next.callback(lock))
|
||||
.then(
|
||||
(value) => {
|
||||
this.release(name)
|
||||
next.resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.release(name)
|
||||
next.reject(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private release(name: string): void {
|
||||
this.held.delete(name)
|
||||
this.grant(name)
|
||||
}
|
||||
}
|
||||
|
||||
let previous: PropertyDescriptor | undefined
|
||||
|
||||
/** navigator.locks 를 가짜로 바꾼다 */
|
||||
export function installFakeLocks(): FakeLockManager {
|
||||
const locks = new FakeLockManager()
|
||||
previous = Object.getOwnPropertyDescriptor(navigator, 'locks')
|
||||
Object.defineProperty(navigator, 'locks', { configurable: true, value: locks })
|
||||
return locks
|
||||
}
|
||||
|
||||
export function uninstallFakeLocks(): void {
|
||||
if (previous) Object.defineProperty(navigator, 'locks', previous)
|
||||
else Reflect.deleteProperty(navigator, 'locks')
|
||||
previous = undefined
|
||||
}
|
||||
Reference in New Issue
Block a user