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:
Generated
+6
-1
@@ -20,7 +20,12 @@
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
"vite": "^8.2.0",
|
||||
"workbox-cacheable-response": "^7.4.1",
|
||||
"workbox-expiration": "^7.4.1",
|
||||
"workbox-precaching": "^7.4.1",
|
||||
"workbox-routing": "^7.4.1",
|
||||
"workbox-strategies": "^7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@apideck/better-ajv-errors": {
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
"vite": "^8.2.0",
|
||||
"workbox-cacheable-response": "^7.4.1",
|
||||
"workbox-expiration": "^7.4.1",
|
||||
"workbox-precaching": "^7.4.1",
|
||||
"workbox-routing": "^7.4.1",
|
||||
"workbox-strategies": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { apiFetch } from './client'
|
||||
|
||||
export interface PushSubscriptionPayload {
|
||||
endpoint: string
|
||||
keys: { p256dh: string; auth: string }
|
||||
}
|
||||
|
||||
interface VapidPublicKeyResponse {
|
||||
public_key: string | null
|
||||
}
|
||||
|
||||
export function getVapidPublicKey(): Promise<VapidPublicKeyResponse> {
|
||||
return apiFetch<VapidPublicKeyResponse>('/api/push/vapid-public-key')
|
||||
}
|
||||
|
||||
export function subscribePush(subscription: PushSubscriptionPayload): Promise<void> {
|
||||
return apiFetch<void>('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(subscription),
|
||||
})
|
||||
}
|
||||
|
||||
export function unsubscribePush(endpoint: string): Promise<void> {
|
||||
return apiFetch<void>('/api/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ endpoint }),
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { getVapidPublicKey, subscribePush, unsubscribePush } from '../api/push'
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
const outputArray = new Uint8Array(new ArrayBuffer(rawData.length))
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
export function isPushSupported(): boolean {
|
||||
return 'serviceWorker' in navigator && 'PushManager' in window
|
||||
}
|
||||
|
||||
export async function getPushSubscriptionStatus(): Promise<boolean> {
|
||||
if (!isPushSupported()) return false
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
return subscription !== null
|
||||
}
|
||||
|
||||
export async function subscribeToPush(): Promise<void> {
|
||||
if (!isPushSupported()) {
|
||||
throw new Error('Push notifications are not supported in this browser')
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission was not granted')
|
||||
}
|
||||
|
||||
const { public_key } = await getVapidPublicKey()
|
||||
if (!public_key) {
|
||||
throw new Error('Push notifications are not configured on the server')
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key),
|
||||
})
|
||||
|
||||
const json = subscription.toJSON()
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
throw new Error('Push subscription is missing required fields')
|
||||
}
|
||||
|
||||
await subscribePush({
|
||||
endpoint: json.endpoint,
|
||||
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
|
||||
})
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPush(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) return
|
||||
await unsubscribePush(subscription.endpoint)
|
||||
await subscription.unsubscribe()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
|
||||
import { NavigationRoute, registerRoute } from 'workbox-routing'
|
||||
import { NetworkOnly, StaleWhileRevalidate } from 'workbox-strategies'
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
self.skipWaiting()
|
||||
cleanupOutdatedCaches()
|
||||
|
||||
// The app shell -- same effect generateSW gave us automatically in Phase 3.
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
registerRoute(
|
||||
new NavigationRoute(createHandlerBoundToURL('index.html'), {
|
||||
denylist: [/^\/api/, /^\/ws/],
|
||||
}),
|
||||
)
|
||||
|
||||
const READ_CACHE_EXPIRATION = { maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 }
|
||||
const cacheableResponse = new CacheableResponsePlugin({ statuses: [0, 200] })
|
||||
|
||||
// Ported from Phase 3's vite.config.ts `workbox.runtimeCaching` -- that
|
||||
// option only applies to the generateSW strategy, so with a hand-written
|
||||
// service worker (required below for the push/notificationclick handlers)
|
||||
// these routes have to be registered explicitly instead.
|
||||
registerRoute(({ url }) => url.pathname.startsWith('/api/auth/'), new NetworkOnly())
|
||||
|
||||
registerRoute(
|
||||
({ url }) => url.pathname === '/api/rooms/mine',
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'api-rooms-mine',
|
||||
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
|
||||
}),
|
||||
)
|
||||
registerRoute(
|
||||
({ url }) => url.pathname === '/api/rooms',
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'api-rooms-open',
|
||||
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
|
||||
}),
|
||||
)
|
||||
registerRoute(
|
||||
({ url }) => /^\/api\/rooms\/[^/]+\/messages$/.test(url.pathname),
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'api-room-messages',
|
||||
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
|
||||
}),
|
||||
)
|
||||
registerRoute(
|
||||
({ url }) => /^\/api\/rooms\/[^/]+\/members$/.test(url.pathname),
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'api-room-members',
|
||||
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
|
||||
}),
|
||||
)
|
||||
registerRoute(
|
||||
({ url }) => url.pathname === '/api/invites/mine',
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'api-invites-mine',
|
||||
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
|
||||
}),
|
||||
)
|
||||
// Defensive default: anything else under /api/ stays network-only until
|
||||
// explicitly opted in above.
|
||||
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly())
|
||||
|
||||
interface PushPayload {
|
||||
title: string
|
||||
body: string
|
||||
room_id: string
|
||||
}
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return
|
||||
let payload: PushPayload
|
||||
try {
|
||||
payload = event.data.json()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title, {
|
||||
body: payload.body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
data: { room_id: payload.room_id },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const roomId = (event.notification.data as { room_id?: string } | undefined)?.room_id
|
||||
const targetUrl = roomId ? `/rooms/${roomId}` : '/rooms'
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
for (const client of clientsList) {
|
||||
if ('focus' in client) {
|
||||
await client.navigate(targetUrl)
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(targetUrl)
|
||||
})(),
|
||||
)
|
||||
})
|
||||
@@ -22,5 +22,6 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/sw.ts"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.sw.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.sw.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "WebWorker"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"moduleDetection": "force",
|
||||
"erasableSyntaxOnly": true
|
||||
},
|
||||
"include": ["src/sw.ts"]
|
||||
}
|
||||
+14
-76
@@ -2,16 +2,25 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
const READ_CACHE_EXPIRATION = {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
// generateSW (Phase 3) can't add custom event listeners, and push /
|
||||
// notificationclick need exactly that -- injectManifest means we hand-
|
||||
// write the service worker (src/sw.ts); its runtime-caching routes are
|
||||
// registered there directly instead of via the `workbox` option below
|
||||
// (which only applies to generateSW).
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
injectManifest: {
|
||||
// Workbox's default globPatterns exclude the manifest's own output
|
||||
// dir, which is fine, but be explicit about what the app shell
|
||||
// precache should contain.
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest}'],
|
||||
},
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'KeepItTalking',
|
||||
@@ -31,77 +40,6 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
|
||||
// urlPattern uses function matchers against url.pathname rather than
|
||||
// RegExp (which Workbox tests against the *full href*, origin
|
||||
// included -- a `^/api/` anchor would silently never match).
|
||||
runtimeCaching: [
|
||||
// Never serve a stale cached "who am I" response.
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname.startsWith('/api/auth/'),
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
// Cache-and-refresh: show the last known list/history immediately,
|
||||
// update from the network in the background. Routes default to
|
||||
// matching GET only, so mutations to these same paths are
|
||||
// untouched and still go straight to network.
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname === '/api/rooms/mine',
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-rooms-mine',
|
||||
expiration: READ_CACHE_EXPIRATION,
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname === '/api/rooms',
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-rooms-open',
|
||||
expiration: READ_CACHE_EXPIRATION,
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url }) =>
|
||||
/^\/api\/rooms\/[^/]+\/messages$/.test(url.pathname),
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-room-messages',
|
||||
expiration: READ_CACHE_EXPIRATION,
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url }) =>
|
||||
/^\/api\/rooms\/[^/]+\/members$/.test(url.pathname),
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-room-members',
|
||||
expiration: READ_CACHE_EXPIRATION,
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname === '/api/invites/mine',
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-invites-mine',
|
||||
expiration: READ_CACHE_EXPIRATION,
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
// Defensive default: anything else under /api/ (including any
|
||||
// future GET endpoint) stays network-only until explicitly opted
|
||||
// in above.
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user