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:
@@ -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 }: {
|
||||
|
||||
37
src/hooks/usePushSubscription.ts
Normal file
37
src/hooks/usePushSubscription.ts
Normal 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])
|
||||
}
|
||||
@@ -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
42
src/lib/push.ts
Normal 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
|
||||
}
|
||||
@@ -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(() => { /**/ })
|
||||
|
||||
Reference in New Issue
Block a user