chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
52 changed files with 5234 additions and 38 deletions
Showing only changes of commit 50165bd3b7 - Show all commits
+9
View File
@@ -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

+16 -1
View File
@@ -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')
+16 -2
View File
@@ -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 {
+5 -2
View File
@@ -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)
})
})
})
+545
View File
@@ -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)
})
})
})
+83 -19
View File
@@ -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 }
}
+9 -1
View File
@@ -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 }
}
+5 -2
View File
@@ -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'
+7 -1
View File
@@ -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: '메일 알림' },
},
],
},
{
+4
View File
@@ -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
+1 -1
View File
@@ -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,
})
})
})
+142
View File
@@ -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()
})
})
+110
View File
@@ -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,
})
})
})
+148
View File
@@ -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'])
})
})
+120
View File
@@ -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')
})
})
+157
View File
@@ -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 }
})
+1
View File
@@ -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,
+10
View File
@@ -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,
}
}
+8
View File
@@ -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) */
+125
View File
@@ -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
},
}
}
+76
View File
@@ -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
}
}
+6
View File
@@ -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 },
},
],
})
}
+95
View File
@@ -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
}