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 }: {