feat: web push — VAPID build-arg + enable-notifications button
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-28 12:51:08 +03:00
parent 4011d04d1e
commit 5e30761742
5 changed files with 144 additions and 23 deletions
+2
View File
@@ -1,6 +1,8 @@
# --- Frontend build stage --- # --- Frontend build stage ---
FROM node:22-alpine AS web FROM node:22-alpine AS web
WORKDIR /build/web WORKDIR /build/web
ARG VITE_VAPID_PUBLIC_KEY
ENV VITE_VAPID_PUBLIC_KEY=$VITE_VAPID_PUBLIC_KEY
COPY web/package.json web/package-lock.json ./ COPY web/package.json web/package-lock.json ./
RUN npm ci RUN npm ci
COPY web/ . COPY web/ .
+2
View File
@@ -3,6 +3,8 @@ services:
build: build:
context: .. context: ..
dockerfile: Dockerfile dockerfile: Dockerfile
args:
VITE_VAPID_PUBLIC_KEY: ${MESSENGER_VAPID_PUBLIC_KEY:-}
image: messenger/api:latest image: messenger/api:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
+59 -2
View File
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import Avatar, { GroupAvatar } from './Avatar' import Avatar, { GroupAvatar } from './Avatar'
import { IconLogout, IconPlus, IconSearch } from './icons' import { IconBell, IconCheck, IconLogout, IconPlus, IconSearch } from './icons'
import { formatListTime, peerForConversation, userTitle } from '../lib/format' import { formatListTime, peerForConversation, userTitle } from '../lib/format'
import { isIosInstallRequired, isPushSupported, notificationPermission, subscribeToPush } from '../lib/push'
import type { Conversation, User } from '../lib/types' import type { Conversation, User } from '../lib/types'
interface SidebarProps { interface SidebarProps {
@@ -53,6 +54,17 @@ export default function Sidebar({
onLogout, onLogout,
}: SidebarProps) { }: SidebarProps) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [pushSupported] = useState(() => isPushSupported())
const [iosInstallRequired] = useState(() => isIosInstallRequired())
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => notificationPermission())
const [subscribing, setSubscribing] = useState(false)
const handleEnablePush = useCallback(async () => {
setSubscribing(true)
const granted = await subscribeToPush()
setSubscribing(false)
setPermission(granted ? 'granted' : notificationPermission())
}, [])
const items = useMemo(() => { const items = useMemo(() => {
const term = query.trim().toLowerCase() const term = query.trim().toLowerCase()
@@ -233,6 +245,51 @@ export default function Sidebar({
</ul> </ul>
</div> </div>
{pushSupported && (
<div className="border-t border-line/80 px-4 py-2.5">
{permission === 'granted' ? (
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-ghost text-accent">
<IconBell className="h-4 w-4" />
</span>
<span className="flex items-center gap-1.5 text-xs font-medium text-fg/90">
<IconCheck className="h-3.5 w-3.5 text-accent" />
Уведомления включены
</span>
</div>
) : permission === 'denied' ? (
<div className="flex items-start gap-2.5">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-faint">
<IconBell className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block text-xs font-medium text-fg/80">Уведомления заблокированы</span>
<span className="mt-0.5 block text-[11px] leading-snug text-faint">
Разрешите их в настройках браузера
</span>
</span>
</div>
) : (
<button
type="button"
onClick={() => void handleEnablePush()}
disabled={subscribing}
className="flex w-full items-center gap-2.5 rounded-xl border border-line bg-surface px-3 py-2 text-left text-xs font-medium text-fg transition hover:border-accent/50 hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-60"
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-ghost text-accent">
<IconBell className="h-4 w-4" />
</span>
{subscribing ? 'Включаем…' : 'Включить уведомления'}
</button>
)}
{iosInstallRequired && permission !== 'granted' && (
<p className="mt-2 text-[11px] leading-snug text-faint">
На iOS уведомления работают только после добавления на экран «Домой».
</p>
)}
</div>
)}
<div className="flex items-center gap-3 border-t border-line/80 px-4 py-3"> <div className="flex items-center gap-3 border-t border-line/80 px-4 py-3">
<Avatar name={currentUser.display_name || currentUser.username} seed={currentUser.id} size="md" online /> <Avatar name={currentUser.display_name || currentUser.username} seed={currentUser.id} size="md" online />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
+14
View File
@@ -157,6 +157,20 @@ export function IconTrash({ className }: IconProps) {
) )
} }
export function IconBell({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M6 9.5a6 6 0 0 1 12 0c0 3.2.7 5 1.4 6.1.3.5 0 1.1-.6 1.1H5.2c-.6 0-.9-.6-.6-1.1C5.3 14.5 6 12.7 6 9.5Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M10 19.5a2 2 0 0 0 4 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
export function IconSmile({ className }: IconProps) { export function IconSmile({ className }: IconProps) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
+67 -21
View File
@@ -1,5 +1,9 @@
import { api } from './api' import { api } from './api'
interface StandaloneNavigator extends Navigator {
standalone?: boolean
}
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> { function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4) const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
@@ -19,31 +23,73 @@ function encodeKey(key: ArrayBuffer | null): string {
return btoa(binary) return btoa(binary)
} }
export async function ensurePushSubscription(): Promise<void> { export function isPushSupported(): boolean {
const publicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY if (typeof window === 'undefined' || typeof navigator === 'undefined') return false
if (!publicKey) return if (!import.meta.env.VITE_VAPID_PUBLIC_KEY) return false
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}
export function notificationPermission(): NotificationPermission | 'unsupported' {
if (typeof window === 'undefined' || !('Notification' in window)) return 'unsupported'
return Notification.permission
}
export function isIosInstallRequired(): boolean {
if (typeof navigator === 'undefined') return false
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent)
if (!isIos) return false
const standalone = (navigator as StandaloneNavigator).standalone === true
return !standalone
}
async function syncSubscription(publicKey: string, createIfMissing: boolean): Promise<boolean> {
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
if (!createIfMissing) return false
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
}
await api.subscribePush({
endpoint: subscription.endpoint,
keys: {
p256dh: encodeKey(subscription.getKey('p256dh')),
auth: encodeKey(subscription.getKey('auth')),
},
})
return true
}
/**
* Requests notification permission and subscribes to Web Push.
* MUST only be called from a user gesture (e.g. a click handler).
*/
export async function subscribeToPush(): Promise<boolean> {
if (!isPushSupported()) return false
const publicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (!publicKey) return false
try { try {
const permission = await Notification.requestPermission() const permission = await Notification.requestPermission()
if (permission !== 'granted') return if (permission !== 'granted') return false
return await syncSubscription(publicKey, true)
} catch {
return false
}
}
const registration = await navigator.serviceWorker.ready /**
let subscription = await registration.pushManager.getSubscription() * Silent background sync: re-registers an already granted subscription on the
if (!subscription) { * backend in case it lost the record. Never prompts for permission.
subscription = await registration.pushManager.subscribe({ */
userVisibleOnly: true, export async function ensurePushSubscription(): Promise<void> {
applicationServerKey: urlBase64ToUint8Array(publicKey), if (!isPushSupported()) return
}) if (notificationPermission() !== 'granted') return
} const publicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (!publicKey) return
await api.subscribePush({ try {
endpoint: subscription.endpoint, await syncSubscription(publicKey, false)
keys: {
p256dh: encodeKey(subscription.getKey('p256dh')),
auth: encodeKey(subscription.getKey('auth')),
},
})
} catch { } catch {
return return
} }