feat: PC 데스크톱 알림(웹 푸시) 추가 — 인앱/SSE에 OS 토스트 연동
카카오톡처럼 PC에 OS 토스트가 뜨도록 Web Notifications(Level1) +
Web Push/서비스워커(Level2)를 기존 인앱·SSE 알림 위에 얹는다.
중복 방지: 페이지가 열려 있으면 SSE 로컬 알림(포커스면 인앱만),
닫혀 있으면 서비스워커 푸시가 담당한다.
- 백엔드: push_subscriptions 엔티티 + WebPushService(VAPID 설정·
type→{title,body,url} 포매터·만료 구독 정리), notify()가 SSE와
함께 푸시 발송, push/subscribe·unsubscribe 엔드포인트, 마이그레이션,
web-push 의존성
- 프론트: public/sw.js(push/클릭), usePush 컴포저블(권한·구독, 결과
안내), notification.store 로컬 토스트, AppShell 'PC 알림 켜기' 버튼
- env/compose: VAPID 키 주입(공개키→프론트, 비밀키→백엔드)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable */
|
||||
// Relay 서비스워커 — 웹 푸시(Level2): 사이트/탭을 닫아도 OS 토스트를 띄운다.
|
||||
// 푸시 페이로드는 백엔드 WebPushService 가 { title, body, url } 로 보낸다.
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// 새 워커를 즉시 활성화
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return
|
||||
let payload = {}
|
||||
try {
|
||||
payload = event.data.json()
|
||||
} catch (e) {
|
||||
payload = { title: 'Relay', body: '새 알림이 도착했습니다' }
|
||||
}
|
||||
const title = payload.title || 'Relay'
|
||||
const body = payload.body || ''
|
||||
const url = payload.url || null
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// 열린 클라이언트(탭)가 있으면 페이지의 SSE 가 이미 토스트를 처리 → 중복 표시 생략
|
||||
const clients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
})
|
||||
if (clients.length > 0) return
|
||||
await self.registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/relay-icon-256.png',
|
||||
badge: '/relay-icon-256.png',
|
||||
data: { url },
|
||||
})
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const url = (event.notification.data && event.notification.data.url) || '/'
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
})
|
||||
// 이미 열린 창이 있으면 포커스 후 해당 경로로 이동
|
||||
for (const client of clients) {
|
||||
if ('focus' in client) {
|
||||
await client.focus()
|
||||
if ('navigate' in client && url) {
|
||||
try {
|
||||
await client.navigate(url)
|
||||
} catch (e) {
|
||||
/* 이동 실패 무시 */
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
// 열린 창이 없으면 새 창
|
||||
if (self.clients.openWindow) await self.clients.openWindow(url)
|
||||
})(),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user