generated from ttipo/project-base
feat(frontend): 실시간 새 메일 · 바탕화면 알림 · 설정 › 알림
- 로그인한 동안 앱 전체에서 실시간 연결 — 브라우저마다 하나(Web Locks 잠금 + BroadcastChannel 로 다른 탭에 나눔), fetch 로 읽기, 401 은 세션 갱신 한 번, 자리 잡은 뒤에만 다시 잇기 간격을 처음부터(열리자마자 끝나는 연결에서 갱신 폭주 막기), 탭을 닫으면 이어받기 - 메일 화면: 연결이 살아 있는 동안 1분 확인을 멈추고, 바뀌면 1초 모아 맞추기 · 숨은 탭은 탭 제목 (N) 만 - 바탕화면 알림: 웹메일을 보고 있지 않을 때만(보이고 초점이 있는 탭 없음), 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로, 돌아오면 닫기 - 설정 › 메일 › 알림(허락 상태 · 제목 숨김 · 시험 알림 · 연결 상태)과 목록 위 한 번 권하기 줄, 알림 아이콘 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// 단위 테스트 전용 — SSE(text/event-stream) 를 돌려주는 가짜 fetch. 연결마다 테스트가 이벤트를 보내고 · 닫고 · 끊는다.
|
||||
// 응답 차례는 테스트가 정한다(reply) — 정하지 않으면 200 스트림. 클라이언트가 그만두면(AbortController) 스트림이 AbortError 로 끝난다.
|
||||
// 사용: const server = createEventStreamServer(); useMailRealtime({ fetch: server.fetch, … })
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
|
||||
/** 연결 하나의 응답 — 200 스트림 · 다른 상태(본문 JSON) · 네트워크 오류 · 머리글이 오지 않음(그만둘 때까지) */
|
||||
export type EventStreamReply =
|
||||
| { kind: 'stream'; contentType?: string }
|
||||
| { kind: 'status'; status: number; body?: unknown }
|
||||
| { kind: 'network' }
|
||||
| { kind: 'hang' }
|
||||
|
||||
export interface EventStreamConnection {
|
||||
url: string
|
||||
init: RequestInit
|
||||
/** 클라이언트가 그만두었다 */
|
||||
readonly aborted: boolean
|
||||
/** SSE 글을 그대로 보낸다 (조각 시험용) */
|
||||
send: (text: string) => void
|
||||
/** 이벤트 하나 — data 는 JSON 으로 */
|
||||
emit: (event: string, data: unknown) => void
|
||||
/** 서버가 닫는다 (스트림 끝) */
|
||||
close: () => void
|
||||
/** 연결이 끊긴다 (읽기 오류) */
|
||||
fail: () => void
|
||||
}
|
||||
|
||||
export interface EventStreamServer {
|
||||
fetch: (input: string, init?: RequestInit) => Promise<Response>
|
||||
/** 받은 연결 — 스트림 응답만이 아니라 모든 요청 */
|
||||
readonly connections: EventStreamConnection[]
|
||||
/** 다음 요청들의 응답 차례 */
|
||||
reply: (...replies: EventStreamReply[]) => void
|
||||
/** 마지막 연결 */
|
||||
last: () => EventStreamConnection
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException('그만두었다', 'AbortError')
|
||||
}
|
||||
|
||||
export function createEventStreamServer(): EventStreamServer {
|
||||
const connections: EventStreamConnection[] = []
|
||||
const queue: EventStreamReply[] = []
|
||||
|
||||
function open(url: string, init: RequestInit, reply: EventStreamReply): Promise<Response> {
|
||||
let aborted = false
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
||||
let finished = false
|
||||
let rejectPending: ((reason: unknown) => void) | null = null
|
||||
|
||||
const connection: EventStreamConnection = {
|
||||
url,
|
||||
init,
|
||||
get aborted() {
|
||||
return aborted
|
||||
},
|
||||
send: (text) => {
|
||||
if (!finished) controller?.enqueue(encoder.encode(text))
|
||||
},
|
||||
emit: (event, data) => connection.send(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
|
||||
close: () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
controller?.close()
|
||||
},
|
||||
fail: () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
controller?.error(new TypeError('network error'))
|
||||
},
|
||||
}
|
||||
connections.push(connection)
|
||||
|
||||
init.signal?.addEventListener('abort', () => {
|
||||
aborted = true
|
||||
rejectPending?.(abortError())
|
||||
if (!finished) {
|
||||
finished = true
|
||||
controller?.error(abortError())
|
||||
}
|
||||
})
|
||||
|
||||
if (reply.kind === 'network') return Promise.reject(new TypeError('Failed to fetch'))
|
||||
if (reply.kind === 'hang') {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
rejectPending = reject
|
||||
})
|
||||
}
|
||||
if (reply.kind === 'status') {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(reply.body ?? { success: false }), {
|
||||
status: reply.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(streamController) {
|
||||
controller = streamController
|
||||
},
|
||||
})
|
||||
return Promise.resolve(
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': reply.contentType ?? 'text/event-stream; charset=utf-8' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
fetch: (input, init = {}) => open(input, init, queue.shift() ?? { kind: 'stream' }),
|
||||
connections,
|
||||
reply: (...replies) => {
|
||||
queue.push(...replies)
|
||||
},
|
||||
last: () => {
|
||||
const connection = connections.at(-1)
|
||||
if (!connection) throw new Error('아직 연결이 없다')
|
||||
return connection
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// 단위 테스트 전용 — 브라우저 Notification 대역. 띄운 알림을 모아 두고, 허락 묻기의 답을 테스트가 정한다.
|
||||
// 사용: vi.stubGlobal('Notification', FakeNotification) — 테스트마다 FakeNotification.reset() 으로 비운다.
|
||||
// - nextAnswer: 다음 requestPermission 의 답 · holdAnswer(): 답을 미뤄 두고(묻는 창이 떠 있는 동안) 나중에 준다
|
||||
// - constructorThrows: 만들면 예외 — 휴대폰 Chrome 처럼 페이지에서 바로 띄울 수 없는 브라우저
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
|
||||
export class FakeNotification {
|
||||
static permission: NotificationPermission = 'default'
|
||||
/** requestPermission 이 줄 답 — 정하지 않으면 지금 값 */
|
||||
static nextAnswer: NotificationPermission | null = null
|
||||
static requestCount = 0
|
||||
static constructorThrows = false
|
||||
static readonly shown: FakeNotification[] = []
|
||||
/** 다음 허락 묻기의 답을 미룬다 (holdAnswer) */
|
||||
private static holding = false
|
||||
/** 미뤄 둔 답을 주는 함수 */
|
||||
private static held: ((answer: NotificationPermission) => void) | null = null
|
||||
|
||||
readonly title: string
|
||||
readonly options: NotificationOptions & { renotify?: boolean }
|
||||
onclick: ((this: FakeNotification, event: Event) => unknown) | null = null
|
||||
closed = false
|
||||
|
||||
constructor(title: string, options: NotificationOptions & { renotify?: boolean } = {}) {
|
||||
if (FakeNotification.constructorThrows) {
|
||||
throw new TypeError("Failed to construct 'Notification': Illegal constructor.")
|
||||
}
|
||||
if (FakeNotification.permission !== 'granted') throw new TypeError('허락하지 않은 알림')
|
||||
this.title = title
|
||||
this.options = options
|
||||
FakeNotification.shown.push(this)
|
||||
}
|
||||
|
||||
static requestPermission(): Promise<NotificationPermission> {
|
||||
FakeNotification.requestCount += 1
|
||||
if (FakeNotification.held === null && FakeNotification.holding) {
|
||||
FakeNotification.holding = false
|
||||
return new Promise((resolve) => {
|
||||
FakeNotification.held = (answer) => {
|
||||
FakeNotification.held = null
|
||||
FakeNotification.permission = answer
|
||||
resolve(answer)
|
||||
}
|
||||
})
|
||||
}
|
||||
const answer = FakeNotification.nextAnswer ?? FakeNotification.permission
|
||||
FakeNotification.nextAnswer = null
|
||||
FakeNotification.permission = answer
|
||||
return Promise.resolve(answer)
|
||||
}
|
||||
|
||||
/** 다음 허락 묻기의 답을 미룬다 — 돌려준 함수로 답한다 (묻는 창이 떠 있는 동안을 시험한다) */
|
||||
static holdAnswer(): (answer: NotificationPermission) => void {
|
||||
FakeNotification.holding = true
|
||||
return (answer) => FakeNotification.held?.(answer)
|
||||
}
|
||||
|
||||
/** 사용자가 알림을 눌렀다 */
|
||||
click(): void {
|
||||
this.onclick?.call(this, new Event('click'))
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true
|
||||
}
|
||||
|
||||
static reset(permission: NotificationPermission = 'default'): void {
|
||||
FakeNotification.permission = permission
|
||||
FakeNotification.nextAnswer = null
|
||||
FakeNotification.requestCount = 0
|
||||
FakeNotification.constructorThrows = false
|
||||
FakeNotification.holding = false
|
||||
FakeNotification.held = null
|
||||
FakeNotification.shown.length = 0
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ export function createTestRouter(): Router {
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/mail/notify',
|
||||
name: 'settings-mail-notify',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// 단위 테스트 전용 — Web Locks(navigator.locks) 대역. 같은 이름의 잠금은 한 번에 하나만 주고, 나머지는 줄을 세운다.
|
||||
// 표준처럼 잠금은 콜백이 돌려준 Promise 가 끝날 때 풀리고, signal 로 그만두면 기다리던 요청만 AbortError 로 거절한다.
|
||||
// 사용: installFakeLocks() — 끝나면 uninstallFakeLocks(). 여러 "탭"이 같은 navigator.locks 를 쓴다.
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
|
||||
type Callback = (lock: Lock | null) => unknown
|
||||
|
||||
interface Waiter {
|
||||
name: string
|
||||
callback: Callback
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException('잠금 요청을 그만두었다', 'AbortError')
|
||||
}
|
||||
|
||||
export class FakeLockManager {
|
||||
private readonly held = new Map<string, Waiter>()
|
||||
private readonly queues = new Map<string, Waiter[]>()
|
||||
|
||||
request(name: string, ...rest: [Callback] | [LockOptions, Callback]): Promise<unknown> {
|
||||
const [options, callback] = rest.length === 1 ? [{} as LockOptions, rest[0]] : rest
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(abortError())
|
||||
return
|
||||
}
|
||||
const waiter: Waiter = { name, callback, resolve, reject }
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
const queue = this.queues.get(name) ?? []
|
||||
const index = queue.indexOf(waiter)
|
||||
if (index === -1) return
|
||||
queue.splice(index, 1)
|
||||
reject(abortError())
|
||||
})
|
||||
const queue = this.queues.get(name) ?? []
|
||||
queue.push(waiter)
|
||||
this.queues.set(name, queue)
|
||||
this.grant(name)
|
||||
})
|
||||
}
|
||||
|
||||
/** 지금 그 잠금을 가진 요청이 있는지 */
|
||||
isHeld(name: string): boolean {
|
||||
return this.held.has(name)
|
||||
}
|
||||
|
||||
/** 그 잠금을 기다리는 요청 수 */
|
||||
waitingCount(name: string): number {
|
||||
return this.queues.get(name)?.length ?? 0
|
||||
}
|
||||
|
||||
private grant(name: string): void {
|
||||
if (this.held.has(name)) return
|
||||
const next = this.queues.get(name)?.shift()
|
||||
if (!next) return
|
||||
this.held.set(name, next)
|
||||
const lock = { name, mode: 'exclusive' } as Lock
|
||||
void Promise.resolve()
|
||||
.then(() => next.callback(lock))
|
||||
.then(
|
||||
(value) => {
|
||||
this.release(name)
|
||||
next.resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.release(name)
|
||||
next.reject(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private release(name: string): void {
|
||||
this.held.delete(name)
|
||||
this.grant(name)
|
||||
}
|
||||
}
|
||||
|
||||
let previous: PropertyDescriptor | undefined
|
||||
|
||||
/** navigator.locks 를 가짜로 바꾼다 */
|
||||
export function installFakeLocks(): FakeLockManager {
|
||||
const locks = new FakeLockManager()
|
||||
previous = Object.getOwnPropertyDescriptor(navigator, 'locks')
|
||||
Object.defineProperty(navigator, 'locks', { configurable: true, value: locks })
|
||||
return locks
|
||||
}
|
||||
|
||||
export function uninstallFakeLocks(): void {
|
||||
if (previous) Object.defineProperty(navigator, 'locks', previous)
|
||||
else Reflect.deleteProperty(navigator, 'locks')
|
||||
previous = undefined
|
||||
}
|
||||
Reference in New Issue
Block a user