feat: PWA push notifications — service worker, VAPID, subscription management

- Add web-push VAPID backend with push_subscriptions table (migration 078)
- Backend push module with sendPushToUser/sendPushToRoom helpers
- Push routes: GET vapid-key, POST subscribe, DELETE unsubscribe
- Trigger push on new chat messages in direct/group rooms
- PWA manifest, service worker, and icons (72–512px)
- Frontend push lib: SW registration, subscribe/unsubscribe helpers
- Push API in api.ts, SW registered in main.tsx
- usePushSubscription hook for auto-subscribe on permission grant
- PushPermissionRow component in ChatWidget settings view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 12:14:03 +03:00
parent 756a71bf2f
commit 1641b14f29
27 changed files with 1056 additions and 7 deletions

View File

@@ -7,6 +7,7 @@ import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatR
import type { User } from '../../types'
import { useAuth } from '../../contexts/AuthContext'
import { cn } from '../../lib/utils'
import { subscribeToPush, isPushSupported } from '../../lib/push'
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -788,6 +789,9 @@ export function ChatWidget() {
label="Показывать текст в уведомлении" checked={settings.notifShowText !== false}
onChange={v => updateSettings({ notifShowText: v })} />
</SettingsSection>
<SettingsSection title="Push-уведомления">
<PushPermissionRow />
</SettingsSection>
<SettingsSection title="Подсказка">
<p className="text-xs text-slate-500 leading-relaxed">
Правый клик на сообщении реакции, редактирование, удаление.<br />
@@ -1358,6 +1362,59 @@ function ToggleRow({ icon, label, checked, onChange }: {
)
}
// ── PushPermissionRow ─────────────────────────────────────────────────────
function PushPermissionRow() {
const [permission, setPermission] = useState<NotificationPermission>(
typeof Notification !== 'undefined' ? Notification.permission : 'default'
)
const [loading, setLoading] = useState(false)
if (!isPushSupported()) {
return (
<p className="text-xs text-slate-400 py-2">
Ваш браузер не поддерживает push-уведомления.
{typeof navigator !== 'undefined' && navigator.userAgent.includes('iPhone') ? ' Добавьте сайт на домашний экран (iOS 16.4+).' : ''}
</p>
)
}
const enable = async () => {
setLoading(true)
try {
const result = await Notification.requestPermission()
setPermission(result)
if (result === 'granted') {
const { publicKey } = await api.push.getVapidKey()
if (!publicKey) return
const sub = await subscribeToPush(publicKey)
if (!sub) return
const json = sub.toJSON()
if (json.endpoint && json.keys?.p256dh && json.keys?.auth) {
await api.push.subscribe({ endpoint: json.endpoint, p256dh: json.keys.p256dh, auth: json.keys.auth })
}
}
} catch { /**/ }
finally { setLoading(false) }
}
return (
<div className="flex items-center gap-3 py-2">
<span className="text-slate-500 shrink-0"><Bell size={15} /></span>
<span className="flex-1 text-sm text-slate-700 dark:text-slate-300 leading-tight">
{permission === 'granted' ? 'Push включены' : permission === 'denied' ? 'Заблокированы в браузере' : 'Push-уведомления'}
</span>
{permission === 'default' && (
<button onClick={enable} disabled={loading}
className="text-xs px-2.5 py-1 rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 transition-colors shrink-0">
{loading ? '...' : 'Включить'}
</button>
)}
{permission === 'granted' && <span className="text-xs text-green-500 shrink-0"></span>}
</div>
)
}
// ── GroupInfoView ──────────────────────────────────────────────────────────
function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEditName, onAvatarClick, onSave }: {

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { registerSW, subscribeToPush, isPushSupported } from '../lib/push'
import { api } from '../lib/api'
export function usePushSubscription() {
const { user } = useAuth()
useEffect(() => {
if (!user || !isPushSupported()) return
if (Notification.permission === 'denied') return
const setup = async () => {
const sw = await registerSW()
if (!sw) return
const { publicKey } = await api.push.getVapidKey()
if (!publicKey) return
if (Notification.permission === 'default') return // wait for explicit request
const sub = await subscribeToPush(publicKey)
if (!sub) return
const json = sub.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) return
await api.push.subscribe({
endpoint: json.endpoint,
p256dh: json.keys.p256dh,
auth: json.keys.auth,
})
}
setup().catch(() => { /**/ })
}, [user])
}

View File

@@ -897,6 +897,15 @@ export const api = {
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/payment-gateways/${id}`),
},
// ── Push notifications ────────────────────────────────────────────────────
push: {
getVapidKey: () => req<{ publicKey: string | null }>('GET', '/api/push/vapid-key'),
subscribe: (sub: { endpoint: string; p256dh: string; auth: string }) =>
req<{ ok: boolean }>('POST', '/api/push/subscribe', sub),
unsubscribe: (endpoint: string) =>
req<{ ok: boolean }>('DELETE', '/api/push/unsubscribe', { endpoint }),
},
// Public widget API (no auth)
widget: {
getConfig: (slug: string) =>

42
src/lib/push.ts Normal file
View File

@@ -0,0 +1,42 @@
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const arr = new Uint8Array([...rawData].map(char => char.charCodeAt(0)))
return arr.buffer as ArrayBuffer
}
export async function registerSW(): Promise<ServiceWorkerRegistration | null> {
if (!('serviceWorker' in navigator)) return null
try {
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' })
return reg
} catch { return null }
}
export async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
if (!('PushManager' in window)) return null
try {
const reg = await navigator.serviceWorker.ready
const existing = await reg.pushManager.getSubscription()
if (existing) return existing
return await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
} catch { return null }
}
export async function unsubscribeFromPush(): Promise<boolean> {
if (!('serviceWorker' in navigator)) return false
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) return sub.unsubscribe()
return false
} catch { return false }
}
export function isPushSupported(): boolean {
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}

View File

@@ -2,9 +2,13 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
import { registerSW } from './lib/push'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
// Register service worker for PWA and push notifications
registerSW().catch(() => { /**/ })