Private
Public Access
Phase 4: Web Push notifications (pywebpush + VAPID)
Backend: PushSubscription model/migration, VAPID config + `cli.py generate-vapid-keys`, push_service.send_push_to_user (upsert-by-endpoint subscribe/unsubscribe, auto-cleanup of expired 404/410 subscriptions), /api/push/* router, and ConnectionManager now tracks connected user IDs per room so chat.py can push only to offline members after broadcasting to online ones. Two test-infra bugs found and fixed along the way: send_push_to_user takes the caller's AsyncSession and is awaited inline rather than fired via asyncio.create_task with its own session (background tasks were outliving the test event loop); and the ws_client fixture now uses NullPool to eliminate a connection-pool checkout race that was failing WS tests intermittently. Frontend: service worker rebuilt with vite-plugin-pwa's injectManifest strategy (custom src/sw.ts) so it can add push/notificationclick handlers alongside the existing precaching and StaleWhileRevalidate routes ported over from generateSW. New subscribe/unsubscribe flow (lib/push.ts, api/push.ts) with a toggle in the account menu. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,3 +94,15 @@
|
||||
.top-bar-menu button[role='menuitem']:hover {
|
||||
background: var(--ds-surface-2);
|
||||
}
|
||||
|
||||
.top-bar-menu button[role='menuitem']:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.top-bar-menu-error {
|
||||
color: var(--ds-danger);
|
||||
font-size: 0.74rem;
|
||||
padding: 2px 8px 6px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import logo from '../assets/logo.png'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { initials } from '../lib/avatar'
|
||||
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
|
||||
import './TopBar.css'
|
||||
|
||||
export function TopBar() {
|
||||
const { user, logout } = useAuth()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [pushSubscribed, setPushSubscribed] = useState(false)
|
||||
const [pushBusy, setPushBusy] = useState(false)
|
||||
const [pushError, setPushError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getPushSubscriptionStatus().then(setPushSubscribed)
|
||||
}, [])
|
||||
|
||||
async function handleTogglePush() {
|
||||
setPushBusy(true)
|
||||
setPushError(null)
|
||||
try {
|
||||
if (pushSubscribed) {
|
||||
await unsubscribeFromPush()
|
||||
setPushSubscribed(false)
|
||||
} else {
|
||||
await subscribeToPush()
|
||||
setPushSubscribed(true)
|
||||
}
|
||||
} catch (err) {
|
||||
setPushError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setPushBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
@@ -32,6 +58,17 @@ export function TopBar() {
|
||||
<div className="top-bar-menu-scrim" onClick={() => setMenuOpen(false)} />
|
||||
<div className="top-bar-menu" role="menu">
|
||||
<div className="top-bar-menu-username">{user.username}</div>
|
||||
{isPushSupported() && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={handleTogglePush}
|
||||
disabled={pushBusy}
|
||||
>
|
||||
{pushSubscribed ? 'Disable notifications' : 'Enable notifications'}
|
||||
</button>
|
||||
)}
|
||||
{pushError && <div className="top-bar-menu-error">{pushError}</div>}
|
||||
<button type="button" role="menuitem" onClick={() => logout()}>
|
||||
Log out
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user