Release 202506091000

This commit is contained in:
pluja
2025-06-09 10:00:55 +00:00
parent 8b90b3eef6
commit 87f0f36aa1
61 changed files with 5216 additions and 730 deletions

View File

@@ -2,12 +2,17 @@
import LoadingIndicator from 'astro-loading-indicator/component'
import { Schema } from 'astro-seo-schema'
import { ClientRouter } from 'astro:transitions'
import { pwaAssetsHead } from 'virtual:pwa-assets/head'
import { pwaInfo } from 'virtual:pwa-info'
import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import DynamicFavicon from './DynamicFavicon.astro'
import HtmxScript from './HtmxScript.astro'
import NotificationEventsScript from './NotificationEventsScript.astro'
import { makeOgImageUrl } from './OgImage'
import ServerEventsScript from './ServerEventsScript.astro'
import TailwindJsPluggin from './TailwindJsPluggin.astro'
import type { ComponentProps } from 'astro/types'
@@ -70,12 +75,6 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
---
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon-lightmode.svg" media="(prefers-color-scheme: light)" />
{DEPLOYMENT_MODE === 'development' && <link rel="icon" type="image/svg+xml" href="/favicon-dev.svg" />}
{DEPLOYMENT_MODE === 'staging' && <link rel="icon" type="image/svg+xml" href="/favicon-stage.svg" />}
<!-- Primary Meta Tags -->
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
@@ -98,7 +97,13 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<!-- Other -->
<link rel="sitemap" href="/sitemap-index.xml" />
<meta name="theme-color" content="#040505" />
<!-- PWA -->
{pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
{pwaAssetsHead.links.map((link) => <link {...link} />)}
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
<DynamicFavicon />
<!-- Components -->
<ClientRouter />
@@ -131,3 +136,11 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
/>
))
}
<!-- Server events -->
<ServerEventsScript />
<!-- Push Notifications -->
<script src="/src/pwa.ts"></script>
<NotificationEventsScript />

View File

@@ -0,0 +1,60 @@
---
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { prisma } from '../lib/prisma'
const user = Astro.locals.user
const hasUnreadNotifications = await Astro.locals.banners.try(
'Error getting unread notification count',
async () =>
user
? !!(await prisma.notification.findFirst({
where: { userId: user.id, read: false },
select: { id: true },
}))
: false,
false
)
function addBadgeIfUnread(href: string) {
if (hasUnreadNotifications) return href.replace('.svg', '-badge.svg')
return href
}
---
{
DEPLOYMENT_MODE === 'production' && (
<>
<link rel="icon" type="image/svg+xml" href={addBadgeIfUnread('/favicon.svg')} />
<link
rel="icon"
type="image/svg+xml"
href={addBadgeIfUnread('/favicon-lightmode.svg')}
media="(prefers-color-scheme: light)"
/>
</>
)
}
{
DEPLOYMENT_MODE === 'development' && (
<link rel="icon" type="image/svg+xml" href={addBadgeIfUnread('/favicon-dev.svg')} />
)
}
{
DEPLOYMENT_MODE === 'staging' && (
<link rel="icon" type="image/svg+xml" href={addBadgeIfUnread('/favicon-stage.svg')} />
)
}
<script>
document.addEventListener('sse-new-notification', () => {
const links = document.querySelectorAll('link[rel="icon"]')
links.forEach((link) => {
const href = link.getAttribute('href')
if (href && href.includes('favicon') && !href.endsWith('-badge.svg')) {
const newHref = href.replace('.svg', '-badge.svg')
link.setAttribute('href', newHref)
}
})
})
</script>

View File

@@ -27,6 +27,8 @@ const count =
user && (
<a
href="/notifications"
data-notification-count-link
data-current-count={count}
class={cn(
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
className
@@ -35,11 +37,32 @@ const count =
{...htmlProps}
>
<Icon name="material-symbols:notifications-outline" class="size-5" />
{count > 0 && (
<span class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500">
{count > 99 ? '★' : count.toLocaleString()}
</span>
)}
<span
data-notification-count-badge
class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500 empty:hidden"
>
{count > 0 ? (count > 99 ? '★' : count.toLocaleString()) : ''}
</span>
</a>
)
}
<script>
document.addEventListener('sse-new-notification', () => {
document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => {
const currentCount = Number(link.getAttribute('data-current-count') || 0)
const newCount = currentCount + 1
link.querySelectorAll<HTMLElement>('[data-notification-count-badge]').forEach((badge) => {
badge.textContent = newCount > 0 ? (newCount > 99 ? '★' : newCount.toLocaleString()) : ''
})
link.setAttribute(
'aria-label',
`Go to notifications${newCount > 0 ? ` (${newCount.toLocaleString()} unread)` : ''}`
)
link.setAttribute('data-current-count', String(newCount))
})
})
</script>

View File

@@ -0,0 +1,29 @@
---
---
<script>
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
import { makeNotificationOptions } from '../lib/notificationOptions'
document.addEventListener('sse-new-notification', (event) => {
if (isBrowserNotificationsEnabled()) {
const payload = event.detail
const notification = showBrowserNotification(
payload.title,
makeNotificationOptions(payload, { removeActions: true })
)
// Handle notification click
if (notification) {
notification.onclick = () => {
const defaultAction = payload.actions.find((a) => a.url) ?? payload.actions[0]
if (defaultAction?.url) {
window.open(defaultAction.url, '_blank')
}
notification.close()
}
}
}
})
</script>

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components'
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
import { SUPPORT_EMAIL } from '../constants/project'
import { cn } from '../lib/cn'
import Button from './Button.astro'
@@ -21,24 +22,24 @@ type Props = HTMLAttributes<'div'> & {
}
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
// TODO: Feature flag, enabled only for admins
if (!Astro.locals.user?.admin) {
return null
}
---
<div
data-push-notification-banner
data-dismissed={undefined /* Updated by client script */}
data-supports-push-notifications={undefined /* Updated by client script */}
data-dismissed={undefined as true | undefined /* Updated by client script */}
data-notification-supported={undefined as true | undefined /* Updated by client script */}
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
data-is-enabled={undefined /* Updated by client script */}
data-is-enabled={undefined as true | undefined /* Updated by client script */}
data-loaded={undefined as true | undefined /* Updated by client script */}
data-notification-permissions={undefined as
| NotificationPermission
| undefined /* Updated by client script */}
data-vapid-public-key={VAPID_PUBLIC_KEY}
class={cn(
'no-js:hidden relative isolate flex items-center justify-between gap-x-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4',
'no-js:hidden xs:grid-cols-[auto_auto] xs:justify-between relative isolate grid items-center justify-stretch gap-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4 not-data-loaded:hidden',
'data-dismissed:hidden',
hideIfEnabled && 'data-is-enabled:hidden',
'not-data-supports-push-notifications:hidden',
'not-data-notification-supported:hidden',
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
className
)}
@@ -60,25 +61,18 @@ if (!Astro.locals.user?.admin) {
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
</div>
<div>
<h3 class="font-medium text-blue-100">
<span data-show-if-enabled>Push notifications enabled</span>
<span data-show-if-disabled>Turn on push notifications?</span>
</h3>
<p class="text-sm text-blue-200/80">
<span data-show-if-enabled>Turn notifications off for this device?</span>
<span data-show-if-disabled>Get notifications on this device.</span>
</p>
<h3 class="font-medium text-blue-100" data-banner-title>Turn on push notifications?</h3>
<p class="text-sm text-blue-200/80" data-banner-description>Get notifications on this device.</p>
</div>
</div>
<div class="flex items-center gap-2">
<div class="xs:justify-end flex shrink flex-wrap items-center justify-center gap-2">
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
<Button
as="span"
label="Yes, notify me"
color="white"
data-push-action="subscribe"
data-vapid-public-key={VAPID_PUBLIC_KEY}
data-show-if-disabled
/>
<Button
@@ -87,10 +81,21 @@ if (!Astro.locals.user?.admin) {
color="white"
variant="faded"
data-push-action="unsubscribe"
data-vapid-public-key={VAPID_PUBLIC_KEY}
data-show-if-enabled
/>
</div>
<div
class="col-span-full flex items-center justify-center gap-2 leading-tight text-red-500 has-[[data-error-message]:empty]:hidden"
>
<Icon name="ri:error-warning-line" class="size-5 shrink-0" />
<span>
<span data-error-message></span>
<a href={`mailto:${SUPPORT_EMAIL}`} class="text-red-300 underline hover:text-red-200">
Contact support
</a>
</span>
</div>
</div>
<script>
@@ -134,240 +139,128 @@ if (!Astro.locals.user?.admin) {
</script>
<script>
/////////////////////////////////////////////////////////
// Script to style when notifications enabled. //
////////////////////////////////////////////////////////
///////////////////////////////////////////
// Script to handle push notifications. //
///////////////////////////////////////////
type ServerSubscription = {
endpoint: string
userAgent: string | null
}
import {
subscribeToPushNotifications,
unsubscribeFromPushNotifications,
parsePushSubscriptions,
isCurrentDeviceSubscribed,
supportsPushNotifications,
type SafeResult,
} from '../lib/client/clientPushNotifications'
/** Parse push subscriptions from string */
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
try {
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
import {
enableBrowserNotifications,
disableBrowserNotifications,
isBrowserNotificationsEnabled,
supportsBrowserNotifications,
} from '../lib/client/browserNotifications'
const subscriptions = JSON.parse(subscriptionsAsString)
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
if (!subscriptions.every((s) => typeof s === 'object' && s !== null)) {
throw new Error('Push subscriptions must be an array of objects')
}
if (!subscriptions.every((s) => typeof s.endpoint === 'string')) {
throw new Error('Push subscriptions must be an array of objects with endpoint property')
}
if (!subscriptions.every((s) => typeof s.userAgent === 'string' || s.userAgent === null)) {
throw new Error('Push subscriptions must be an array of objects with userAgent property')
}
return subscriptions as ServerSubscription[]
} catch (error) {
console.error('Failed to parse push subscriptions:', error)
return []
async function setDataAttributes(banner: HTMLElement) {
if (!supportsPushNotifications() && !supportsBrowserNotifications()) {
return
}
}
/** Check if current device has an active push subscription */
async function getCurrentPushSubscription() {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return null
banner.dataset.notificationSupported = ''
banner.dataset.notificationPermissions = Notification.permission
return await registration.pushManager.getSubscription()
} catch (error) {
console.error('Error getting current push subscription:', error)
return null
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
const titleElement = banner.querySelector<HTMLElement>('[data-banner-title]')
const descriptionElement = banner.querySelector<HTMLElement>('[data-banner-description]')
if (await isCurrentDeviceSubscribed(serverSubscriptions)) {
if (titleElement) titleElement.textContent = 'Push notifications enabled'
if (descriptionElement) descriptionElement.textContent = 'Turn push notifications off for this device?'
banner.dataset.isEnabled = ''
banner.dataset.loaded = ''
return
}
}
/** Check if current subscription matches any server subscription */
function isCurrentDeviceSubscribed(
currentSubscription: PushSubscription | null,
serverSubscriptions: ServerSubscription[]
) {
if (!currentSubscription || serverSubscriptions.length === 0) return false
if (isBrowserNotificationsEnabled()) {
if (titleElement) titleElement.textContent = 'Browser notifications enabled'
if (descriptionElement)
descriptionElement.textContent = 'Turn off notifications? (They only work while the tab is open.)'
banner.dataset.isEnabled = ''
banner.dataset.loaded = ''
return
}
const currentEndpoint = currentSubscription.endpoint
const currentUserAgent = navigator.userAgent
return serverSubscriptions.some(
(sub) =>
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
)
// Default state (disabled)
if (titleElement) titleElement.textContent = 'Turn on push notifications?'
if (descriptionElement) descriptionElement.textContent = 'Get notifications on this device.'
banner.dataset.loaded = ''
}
document.addEventListener('astro:page-load', async () => {
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
const currentSubscription = await getCurrentPushSubscription()
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
await setDataAttributes(banner)
if (isSubscribed) banner.dataset.isEnabled = ''
})
})
</script>
const vapidPublicKey = banner.dataset.vapidPublicKey
<script>
/////////////////////////////////////////////////////////////
// Script to handle push notification subscription. //
/////////////////////////////////////////////////////////////
banner.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
button.addEventListener('click', async (event) => {
event.preventDefault()
event.stopPropagation()
import type { actions } from 'astro:actions'
import type { ActionInput } from '../lib/astroActions'
const action = button.dataset.pushAction
if (action !== 'subscribe' && action !== 'unsubscribe') {
console.error('Invalid push action:', action)
return
}
/** Utility function to convert VAPID key */
function urlB64ToUint8Array(base64String: string) {
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/\-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
const base64 = cleaned + padding
let result: SafeResult
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
if (action === 'subscribe') {
const pushResult = vapidPublicKey
? await subscribeToPushNotifications(vapidPublicKey)
: { success: false as const, error: 'VAPID public key not found' }
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
if (pushResult.success) {
result = pushResult
} else {
console.error(
"Can't enable push notifications, trying browser notifications.",
pushResult.error
)
const browserResult = await enableBrowserNotifications()
/** Check for browser support */
function checkSupport() {
const isSecure =
window.isSecureContext ||
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1'
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}
if (browserResult.success) {
result = browserResult
} else {
console.error("Can't enable browser notifications:", browserResult.error)
result = {
success: false,
error: `Can't enable either push or browser notifications. Push: ${pushResult.error}. Browser: ${browserResult.error}`,
}
}
}
} else {
const pushResult = await unsubscribeFromPushNotifications()
const browserResult = disableBrowserNotifications()
async function registerServiceWorker() {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('Service Worker registered:', registration)
const success = pushResult.success || browserResult.success
const readyRegistration = await navigator.serviceWorker.ready
console.log('Service Worker is active and ready:', readyRegistration)
result = success
? { success: true }
: { success: false, error: `${pushResult.error} | ${browserResult.error}` }
}
return readyRegistration
} catch (error) {
console.error('Service Worker registration failed:', error)
throw error
}
}
if (!result.success) {
console.error(result.error)
async function subscribeToPush(vapidPublicKey: string) {
try {
if (!checkSupport()) return
const errorMessageElement = banner.querySelector<HTMLElement>('[data-error-message]')
if (errorMessageElement) {
errorMessageElement.textContent = `Failed to ${action === 'subscribe' ? 'enable' : 'disable'} notifications.`
}
// Request notification permission
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
alert('Push notifications permission denied')
return
}
return
}
const registration = await registerServiceWorker()
// Subscribe to push manager
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
})
const p256dh = subscription.getKey('p256dh')
const auth = subscription.getKey('auth')
// Send subscription to server
const response = await fetch('/internal-api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
userAgent: navigator.userAgent,
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
console.log('Push subscription successful')
// Reload page to update UI
window.location.reload()
} catch (error) {
console.error('Push subscription failed:', error)
alert('Error enabling push notifications. This may be due to browser settings or other restrictions.')
}
}
async function unsubscribeFromPush() {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) {
console.log('No service worker registration found')
return
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
console.log('No push subscription found')
return
}
// Unsubscribe from browser
await subscription.unsubscribe()
// Remove from server
const response = await fetch('/internal-api/notifications/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
console.log('Push unsubscription successful')
// Reload page to update UI
window.location.reload()
} catch (error) {
console.error('Push unsubscription failed:', error)
alert('Failed to unsubscribe from push notifications')
}
}
document.addEventListener('astro:page-load', () => {
const supportsPushNotifications = checkSupport()
if (supportsPushNotifications) {
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((element) => {
element.dataset.supportsPushNotifications = ''
})
}
document.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
const vapidPublicKey = button.dataset.vapidPublicKey
if (!vapidPublicKey) {
console.error('Environment variable VAPID_PUBLIC_KEY is not set')
return
}
button.addEventListener('click', async (event) => {
event.preventDefault()
event.stopPropagation()
const action = button.dataset.pushAction
if (action === 'subscribe') {
await subscribeToPush(vapidPublicKey)
} else if (action === 'unsubscribe') {
await unsubscribeFromPush()
}
window.location.reload()
})
})
})
})

View File

@@ -0,0 +1,108 @@
---
if (!Astro.locals.user) return
---
<script>
import type { ServerEventsEvent, SSEEventMap } from '../lib/serverEventsTypes'
const MAX_RECONNECT_ATTEMPTS = 5
const RECONNECT_DELAY = 2_000
let eventSource: EventSource | null = null
let reconnectTimeout: number | null = null
let reconnectAttempts = 0
startServerEventsListener()
// ------------------------------------------------------------
export function startServerEventsListener() {
stopServerEventsListener()
try {
eventSource = new EventSource('/internal-api/server-events')
eventSource.onopen = () => {
reconnectAttempts = 0
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string)
if (isServerEventsEvent(data)) {
const eventType = `sse-${data.type}` as const
document.dispatchEvent(
new CustomEvent(eventType, { detail: data.data }) as SSEEventMap[typeof eventType]
)
} else {
console.error('Invalid server events event:', data)
}
} catch (error) {
console.error('Error parsing server events data:', error)
}
}
eventSource.onerror = (error) => {
console.error('Server events error:', error)
if (eventSource?.readyState === EventSource.CLOSED) {
scheduleReconnect()
}
}
} catch (error) {
console.error('Failed to start server events listener:', error)
scheduleReconnect()
}
}
export function stopServerEventsListener() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
if (eventSource) {
eventSource.close()
eventSource = null
console.info('Disconnected from server events listener')
}
reconnectAttempts = 0
}
function scheduleReconnect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.error('Max reconnection attempts reached, giving up')
return
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
}
const delay = RECONNECT_DELAY * Math.pow(2, reconnectAttempts)
reconnectAttempts++
const delayStr = String(delay)
const attemptsStr = String(reconnectAttempts)
const maxAttemptsStr = String(MAX_RECONNECT_ATTEMPTS)
console.info(`Attempting to reconnect in ${delayStr}ms (attempt ${attemptsStr}/${maxAttemptsStr})`)
reconnectTimeout = window.setTimeout(() => {
startServerEventsListener()
}, delay)
}
function isServerEventsEvent(event: unknown): event is ServerEventsEvent {
if (typeof event !== 'object' || event === null) return false
const e = event as Record<string, unknown>
return (
'type' in e &&
typeof e.type === 'string' &&
'data' in e &&
typeof e.data === 'object' &&
e.data !== null
)
}
</script>