feat: web push — VAPID build-arg + enable-notifications button
This commit is contained in:
@@ -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/ .
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user