feat: Implement push notification system for admin announcements

- Add a notification manager to handle push notifications and integrate with Nostr events.
- Create a push notification service to manage subscription and permission requests.
- Introduce components for notification settings and permission prompts in the UI.
- Update Nostr store to manage push notification state and enable/disable functionality.
- Enhance NostrFeed to send notifications for new admin announcements.
- Implement test notification functionality for development purposes.
This commit is contained in:
padreug 2025-07-03 08:34:05 +02:00
parent 6c1caac84b
commit c05f40f1ec
17 changed files with 1316 additions and 13 deletions

94
debug_admin_posts.js Normal file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env node
// Debug what admin posts exist in the relay
import { SimplePool } from 'nostr-tools'
const RELAY_URL = "ws://127.0.0.1:5001/nostrrelay/mainhub"
const ADMIN_PUBKEYS = [
"4b9d7688eba64565dcb77cc8ab157eca1964d5de9f7afabe03eb5b54a43d9882",
"30b1ab3683fa6cc0e3695849ee1ec29e9fbae4c9e7ddb7452a4ddb37a0660040",
"c116dbc73a8ccd0046a2ecf96c0b0531d3eda650d449798ac5b86ff6e301debe"
]
async function debugAdminPosts() {
const pool = new SimplePool()
console.log('🔍 Checking relay for admin posts...')
console.log('Relay:', RELAY_URL)
console.log('Admin pubkeys:', ADMIN_PUBKEYS.length)
console.log('')
try {
// Check each admin individually
for (let i = 0; i < ADMIN_PUBKEYS.length; i++) {
const pubkey = ADMIN_PUBKEYS[i]
console.log(`👤 Admin ${i + 1}: ${pubkey.slice(0, 16)}...`)
const individualFilter = {
kinds: [1],
authors: [pubkey],
limit: 10
}
const events = []
const sub = pool.subscribeMany([RELAY_URL], [individualFilter], {
onevent(event) {
events.push(event)
},
oneose() {
console.log(` Found ${events.length} posts`)
events.forEach((event, idx) => {
console.log(` ${idx + 1}. "${event.content.slice(0, 60)}${event.content.length > 60 ? '...' : ''}"`)
console.log(` Created: ${new Date(event.created_at * 1000).toLocaleString()}`)
})
console.log('')
}
})
// Wait for events
await new Promise(resolve => setTimeout(resolve, 1000))
sub.close()
}
// Now test the combined filter (what the app uses)
console.log('🔄 Testing combined filter (what the app uses)...')
const combinedFilter = {
kinds: [1],
authors: ADMIN_PUBKEYS,
limit: 50
}
const allEvents = []
const combinedSub = pool.subscribeMany([RELAY_URL], [combinedFilter], {
onevent(event) {
allEvents.push(event)
},
oneose() {
console.log(`📊 Combined filter result: ${allEvents.length} total posts`)
// Group by author
const byAuthor = {}
allEvents.forEach(event => {
const shortPubkey = event.pubkey.slice(0, 16) + '...'
if (!byAuthor[shortPubkey]) byAuthor[shortPubkey] = 0
byAuthor[shortPubkey]++
})
console.log('Posts by author:')
Object.entries(byAuthor).forEach(([author, count]) => {
console.log(` ${author}: ${count} posts`)
})
}
})
await new Promise(resolve => setTimeout(resolve, 1000))
combinedSub.close()
} catch (error) {
console.error('❌ Error:', error)
} finally {
pool.close([RELAY_URL])
}
}
debugAdminPosts()

36
generate-vapid-keys.js Normal file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env node
// Simple VAPID key generator for testing push notifications
// In production, you'd want to use proper cryptographic libraries
console.log('🔑 VAPID Key Generator for Push Notifications')
console.log('')
// Generate a random string for testing
function generateTestKey(length = 64) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
// Generate test VAPID keys
const publicKey = generateTestKey(87) // Base64 URL-safe, typical length
const privateKey = generateTestKey(43) // Base64 URL-safe, typical length
console.log('📋 Add these to your .env file:')
console.log('')
console.log(`VITE_VAPID_PUBLIC_KEY=${publicKey}`)
console.log(`VITE_PUSH_NOTIFICATIONS_ENABLED=true`)
console.log('')
console.log('⚠️ IMPORTANT: These are test keys for development only!')
console.log(' For production, generate proper VAPID keys using:')
console.log(' - web-push library: npx web-push generate-vapid-keys')
console.log(' - online tool: https://vapidkeys.com/')
console.log('')
console.log('🔐 Private key (keep secure, for backend only):')
console.log(`VAPID_PRIVATE_KEY=${privateKey}`)
console.log('')
console.log('✅ Once added, restart your dev server to apply the changes.')

View file

@ -7,7 +7,7 @@
"scripts": {
"dev": "vite --host",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"preview": "vite preview --host",
"analyze": "vite build --mode analyze",
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
"electron:build": "vue-tsc -b && vite build && electron-builder",

126
public/sw.js Normal file
View file

@ -0,0 +1,126 @@
// Custom service worker for push notifications
// This will be merged with Workbox generated SW
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
import { clientsClaim, skipWaiting } from 'workbox-core'
// Precache and route static assets
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
// Take control of all pages immediately
skipWaiting()
clientsClaim()
// Push notification event handler
self.addEventListener('push', (event) => {
console.log('Push event received:', event)
let notificationData = {
title: 'New Announcement',
body: 'You have a new admin announcement',
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
data: {
url: '/',
timestamp: Date.now()
},
tag: 'admin-announcement',
requireInteraction: true,
actions: [
{
action: 'view',
title: 'View',
icon: '/pwa-192x192.png'
},
{
action: 'dismiss',
title: 'Dismiss'
}
]
}
// Parse push data if available
if (event.data) {
try {
const pushData = event.data.json()
notificationData = {
...notificationData,
...pushData
}
} catch (error) {
console.warn('Failed to parse push data:', error)
// Use default notification data
}
}
event.waitUntil(
self.registration.showNotification(notificationData.title, notificationData)
)
})
// Notification click handler
self.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event)
event.notification.close()
const action = event.action
const notificationData = event.notification.data || {}
if (action === 'dismiss') {
return // Just close the notification
}
// Default action or 'view' action - open the app
const urlToOpen = notificationData.url || '/'
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Try to find an existing window with the app
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.focus()
// Navigate to the notification URL if different
if (client.url !== urlToOpen) {
client.navigate(urlToOpen)
}
return
}
}
// If no existing window, open a new one
if (clients.openWindow) {
return clients.openWindow(urlToOpen)
}
})
)
})
// Background sync for offline notification queue (future enhancement)
self.addEventListener('sync', (event) => {
if (event.tag === 'notification-queue') {
event.waitUntil(
// Process any queued notifications when back online
console.log('Background sync: notification-queue')
)
}
})
// Message handler for communication with main app
self.addEventListener('message', (event) => {
console.log('Service worker received message:', event.data)
if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
const { title, body, data } = event.data.payload
self.registration.showNotification(title, {
body,
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
data,
tag: 'manual-notification',
requireInteraction: false
})
}
})

View file

@ -8,9 +8,10 @@ import { Toaster } from '@/components/ui/sonner'
import { useNostr } from '@/composables/useNostr'
import { identity } from '@/composables/useIdentity'
import { toast } from 'vue-sonner'
import { config } from '@/lib/config'
import { useNostrStore } from '@/stores/nostr'
const { isConnected, isConnecting, error, connect, disconnect } = useNostr()
const nostrStore = useNostrStore()
const showPasswordDialog = ref(false)
@ -43,6 +44,9 @@ onMounted(async () => {
// Connect to Nostr relays
await connect()
// Check push notification status
await nostrStore.checkPushNotificationStatus()
})
onUnmounted(() => {

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { NostrClient, type NostrNote } from '@/lib/nostr/client'
import type { NostrNote } from '@/lib/nostr/client'
import { useNostr } from '@/composables/useNostr'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
@ -8,6 +8,8 @@ import { Badge } from '@/components/ui/badge'
import { formatDistanceToNow } from 'date-fns'
import { Megaphone } from 'lucide-vue-next'
import { config, configUtils } from '@/lib/config'
import { notificationManager } from '@/lib/notifications/manager'
import { useNostrStore } from '@/stores/nostr'
const props = defineProps<{
relays?: string[]
@ -19,10 +21,14 @@ const isLoading = ref(true)
const error = ref<Error | null>(null)
const { getClient } = useNostr(props.relays ? { relays: props.relays } : undefined)
const nostrStore = useNostrStore()
// Get admin/moderator pubkeys from centralized config
const adminPubkeys = config.nostr.adminPubkeys
// Track last seen note timestamp to avoid duplicate notifications
let lastSeenTimestamp = Math.floor(Date.now() / 1000)
async function loadNotes() {
try {
isLoading.value = true
@ -98,12 +104,62 @@ function getFeedDescription(): string {
}
}
// Real-time subscription for new notes (especially admin announcements)
let unsubscribe: (() => void) | null = null
async function startRealtimeSubscription() {
try {
const client = getClient()
await client.connect()
// Subscribe to real-time notes
unsubscribe = client.subscribeToNotes((newNote) => {
// Only process notes newer than last seen
if (newNote.created_at > lastSeenTimestamp) {
// Add to feed if it matches our filter
const shouldInclude = shouldIncludeNote(newNote)
if (shouldInclude) {
// Add to beginning of notes array
notes.value.unshift(newNote)
// Limit the array size to prevent memory issues
if (notes.value.length > 100) {
notes.value = notes.value.slice(0, 100)
}
}
// Send notification if appropriate (only for admin announcements when not in announcements feed)
if (props.feedType !== 'announcements' && configUtils.isAdminPubkey(newNote.pubkey)) {
notificationManager.notifyForNote(newNote, nostrStore.account?.pubkey)
}
// Update last seen timestamp
lastSeenTimestamp = Math.max(lastSeenTimestamp, newNote.created_at)
}
})
} catch (error) {
console.error('Failed to start real-time subscription:', error)
}
}
function shouldIncludeNote(note: NostrNote): boolean {
if (props.feedType === 'announcements') {
return adminPubkeys.length > 0 && adminPubkeys.includes(note.pubkey)
}
// For other feed types, include all notes for now
return true
}
onMounted(async () => {
await loadNotes()
// Start real-time subscription after initial load
await startRealtimeSubscription()
})
onUnmounted(() => {
disconnect()
if (unsubscribe) {
unsubscribe()
}
})
function formatDate(timestamp: number): string {

View file

@ -0,0 +1,213 @@
<template>
<div v-if="shouldShowPrompt" class="bg-card border rounded-lg p-4 mb-4">
<div class="flex items-start gap-3">
<Bell class="h-5 w-5 text-primary mt-0.5" />
<div class="flex-1">
<h3 class="font-semibold text-sm mb-1">
Enable Admin Notifications
</h3>
<p class="text-sm text-muted-foreground mb-3">
Get instant notifications for important admin announcements, even when the app is closed.
</p>
<div class="flex gap-2">
<Button
@click="enableNotifications"
:disabled="isLoading"
size="sm"
class="text-xs"
>
<Bell class="h-3 w-3 mr-1" />
{{ isLoading ? 'Enabling...' : 'Enable Notifications' }}
</Button>
<Button
@click="dismiss"
variant="outline"
size="sm"
class="text-xs"
>
Not Now
</Button>
</div>
</div>
<Button
@click="dismiss"
variant="ghost"
size="icon"
class="h-6 w-6"
>
<X class="h-3 w-3" />
</Button>
</div>
</div>
<!-- Status indicator when notifications are enabled -->
<div v-else-if="isSubscribed" class="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg p-3 mb-4">
<div class="flex items-center gap-2">
<BellRing class="h-4 w-4 text-green-600 dark:text-green-400" />
<span class="text-sm text-green-800 dark:text-green-200">
Push notifications enabled for admin announcements
</span>
<Button
@click="disableNotifications"
variant="ghost"
size="sm"
class="ml-auto text-xs text-green-700 dark:text-green-300 hover:text-green-900 dark:hover:text-green-100"
>
Disable
</Button>
</div>
</div>
<!-- Error state -->
<div v-if="error" class="bg-destructive/10 border border-destructive/20 rounded-lg p-3 mb-4">
<div class="flex items-center gap-2">
<AlertCircle class="h-4 w-4 text-destructive" />
<span class="text-sm text-destructive">
{{ error }}
</span>
<Button
@click="clearError"
variant="ghost"
size="sm"
class="ml-auto text-xs"
>
Dismiss
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Bell, BellRing, X, AlertCircle } from 'lucide-vue-next'
import { pushService } from '@/lib/notifications/push'
import { configUtils } from '@/lib/config'
import { toast } from 'vue-sonner'
const shouldShowPrompt = ref(false)
const isSubscribed = ref(false)
const isLoading = ref(false)
const error = ref('')
const dismissed = ref(false)
const props = defineProps<{
autoShow?: boolean
}>()
onMounted(async () => {
await checkNotificationStatus()
// Show prompt automatically if configured and not dismissed
if (props.autoShow && !dismissed.value) {
shouldShowPrompt.value = !isSubscribed.value &&
pushService.isSupported() &&
configUtils.hasPushConfig() &&
pushService.getPermission() !== 'denied'
}
})
async function checkNotificationStatus() {
try {
if (!pushService.isSupported() || !configUtils.hasPushConfig()) {
return
}
isSubscribed.value = await pushService.isSubscribed()
} catch (err) {
console.error('Failed to check notification status:', err)
}
}
async function enableNotifications() {
try {
isLoading.value = true
error.value = ''
if (!pushService.isSupported()) {
throw new Error('Push notifications are not supported by your browser')
}
if (!configUtils.hasPushConfig()) {
throw new Error('Push notifications are not configured')
}
const subscription = await pushService.subscribe()
console.log('Push subscription created:', subscription)
// Here you would typically send the subscription to your backend
// For now, we'll just store it locally and show success
isSubscribed.value = true
shouldShowPrompt.value = false
toast.success('Push notifications enabled! You\'ll receive admin announcements even when the app is closed.')
// TODO: Send subscription to backend
// await sendSubscriptionToBackend(subscription)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to enable notifications'
error.value = message
toast.error(message)
} finally {
isLoading.value = false
}
}
async function disableNotifications() {
try {
await pushService.unsubscribe()
isSubscribed.value = false
toast.success('Push notifications disabled')
// TODO: Remove subscription from backend
// await removeSubscriptionFromBackend()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to disable notifications'
error.value = message
toast.error(message)
}
}
function dismiss() {
shouldShowPrompt.value = false
dismissed.value = true
// Store dismissal in localStorage
localStorage.setItem('notification-prompt-dismissed', Date.now().toString())
}
function clearError() {
error.value = ''
}
// Test notification function (for development)
async function testNotification() {
try {
await pushService.showLocalNotification({
title: '🚨 Test Admin Announcement',
body: 'This is a test notification to verify push notifications are working correctly.',
icon: '/pwa-192x192.png',
tag: 'test-notification',
data: {
url: '/',
type: 'admin-announcement'
}
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to show test notification'
toast.error(message)
}
}
// Expose test function for development
defineExpose({
testNotification,
enableNotifications,
disableNotifications
})
</script>

View file

@ -0,0 +1,222 @@
<template>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Bell class="h-5 w-5" />
Notification Settings
</CardTitle>
<CardDescription>
Configure when and how you receive push notifications
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<!-- Main notification toggle -->
<div class="flex items-center justify-between">
<div>
<Label class="text-base font-medium">Push Notifications</Label>
<p class="text-sm text-muted-foreground">
Receive notifications even when the app is closed
</p>
</div>
<div class="flex items-center gap-2">
<Button
v-if="!notificationsEnabled && !isBlocked"
@click="enableNotifications"
:disabled="isLoading"
size="sm"
>
<Bell class="h-3 w-3 mr-1" />
{{ isLoading ? 'Enabling...' : 'Enable' }}
</Button>
<Button
v-else-if="notificationsEnabled"
@click="disableNotifications"
variant="outline"
size="sm"
>
<BellOff class="h-3 w-3 mr-1" />
Disable
</Button>
<Badge v-else-if="isBlocked" variant="destructive">
Blocked
</Badge>
</div>
</div>
<!-- Notification type preferences -->
<div v-if="notificationsEnabled" class="space-y-4">
<div class="border-t pt-4">
<Label class="text-sm font-medium mb-3 block">Notification Types</Label>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<Label class="text-sm">Admin Announcements</Label>
<p class="text-xs text-muted-foreground">
Important updates from community administrators
</p>
</div>
<input
type="checkbox"
v-model="options.adminAnnouncements"
@change="updateOptions"
class="h-4 w-4 rounded border-gray-300"
/>
</div>
<div class="flex items-center justify-between">
<div>
<Label class="text-sm">Mentions</Label>
<p class="text-xs text-muted-foreground">
When someone mentions you in a post
</p>
</div>
<input
type="checkbox"
v-model="options.mentions"
@change="updateOptions"
class="h-4 w-4 rounded border-gray-300"
/>
</div>
<div class="flex items-center justify-between">
<div>
<Label class="text-sm">Replies</Label>
<p class="text-xs text-muted-foreground">
When someone replies to your posts
</p>
</div>
<input
type="checkbox"
v-model="options.replies"
@change="updateOptions"
class="h-4 w-4 rounded border-gray-300"
/>
</div>
</div>
</div>
<!-- Test notification -->
<div class="border-t pt-4">
<Button
@click="sendTestNotification"
variant="outline"
size="sm"
:disabled="isTestLoading"
>
<TestTube class="h-3 w-3 mr-1" />
{{ isTestLoading ? 'Sending...' : 'Send Test Notification' }}
</Button>
</div>
</div>
<!-- Help text for blocked notifications -->
<div v-if="isBlocked" class="bg-destructive/10 border border-destructive/20 rounded-lg p-3">
<div class="flex items-start gap-2">
<AlertCircle class="h-4 w-4 text-destructive mt-0.5" />
<div class="text-sm">
<p class="text-destructive font-medium mb-1">Notifications Blocked</p>
<p class="text-destructive/80">
You've blocked notifications for this site. To enable them:
</p>
<ol class="list-decimal list-inside mt-2 text-destructive/80 space-y-1">
<li>Click the lock icon in your browser's address bar</li>
<li>Select "Allow" for notifications</li>
<li>Refresh this page</li>
</ol>
</div>
</div>
</div>
<!-- Not supported message -->
<div v-if="!isSupported" class="bg-orange-50 dark:bg-orange-950 border border-orange-200 dark:border-orange-800 rounded-lg p-3">
<div class="flex items-center gap-2">
<AlertCircle class="h-4 w-4 text-orange-600 dark:text-orange-400" />
<span class="text-sm text-orange-800 dark:text-orange-200">
Push notifications are not supported by your browser
</span>
</div>
</div>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Bell, BellOff, TestTube, AlertCircle } from 'lucide-vue-next'
import { useNostrStore } from '@/stores/nostr'
import { notificationManager } from '@/lib/notifications/manager'
import { pushService } from '@/lib/notifications/push'
import { configUtils } from '@/lib/config'
import { toast } from 'vue-sonner'
import { storeToRefs } from 'pinia'
const nostrStore = useNostrStore()
const { notificationsEnabled } = storeToRefs(nostrStore)
const isLoading = ref(false)
const isTestLoading = ref(false)
const options = ref(notificationManager.getOptions())
const isSupported = pushService.isSupported() && configUtils.hasPushConfig()
const isBlocked = ref(false)
onMounted(async () => {
await checkStatus()
})
async function checkStatus() {
isBlocked.value = pushService.getPermission() === 'denied'
await nostrStore.checkPushNotificationStatus()
}
async function enableNotifications() {
try {
isLoading.value = true
await nostrStore.enablePushNotifications()
toast.success('Push notifications enabled!')
await checkStatus()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to enable notifications'
toast.error(message)
} finally {
isLoading.value = false
}
}
async function disableNotifications() {
try {
await nostrStore.disablePushNotifications()
toast.success('Push notifications disabled')
await checkStatus()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to disable notifications'
toast.error(message)
}
}
function updateOptions() {
notificationManager.updateOptions(options.value)
toast.success('Notification preferences updated')
}
async function sendTestNotification() {
try {
isTestLoading.value = true
await notificationManager.sendTestNotification()
toast.success('Test notification sent!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to send test notification'
toast.error(message)
} finally {
isTestLoading.value = false
}
}
</script>

View file

@ -1,5 +1,5 @@
import { ref } from 'vue'
import { NostrClient, type NostrNote } from '@/lib/nostr/client'
import type { NostrNote } from '@/lib/nostr/client'
import { createTextNote, createReaction, createProfileMetadata } from '@/lib/nostr/events'
import { identity } from '@/composables/useIdentity'
import { toast } from 'vue-sonner'

View file

@ -11,9 +11,15 @@ interface ApiConfig {
key: string
}
interface PushConfig {
vapidPublicKey: string
enabled: boolean
}
interface AppConfig {
nostr: NostrConfig
api: ApiConfig
push: PushConfig
support: {
npub: string
}
@ -41,6 +47,10 @@ export const config: AppConfig = {
baseUrl: import.meta.env.VITE_API_BASE_URL || '',
key: import.meta.env.VITE_API_KEY || ''
},
push: {
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY || '',
enabled: Boolean(import.meta.env.VITE_PUSH_NOTIFICATIONS_ENABLED)
},
support: {
npub: import.meta.env.VITE_SUPPORT_NPUB || ''
}
@ -60,6 +70,10 @@ export const configUtils = {
return Boolean(config.api.baseUrl && config.api.key)
},
hasPushConfig: (): boolean => {
return Boolean(config.push.vapidPublicKey && config.push.enabled)
},
getDefaultRelays: (): string[] => {
return config.nostr.relays
}

View file

@ -1,5 +1,5 @@
import { SimplePool, type Filter, type Event, type UnsignedEvent } from 'nostr-tools'
import { extractReactions, extractReplyCounts, getReplyInfo, EventKinds } from './events'
import { SimplePool, type Filter, type Event } from 'nostr-tools'
import { getReplyInfo, EventKinds } from './events'
export interface NostrClientConfig {
relays: string[]
@ -61,7 +61,6 @@ export class NostrClient {
} = {}): Promise<NostrNote[]> {
const {
limit = 20,
since = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000), // Last 7 days
authors,
includeReplies = false
} = options
@ -135,7 +134,7 @@ export class NostrClient {
/**
* Publish an event to all connected relays
*/
async publishEvent(event: UnsignedEvent): Promise<void> {
async publishEvent(event: Event): Promise<void> {
if (!this._isConnected) {
throw new Error('Not connected to any relays')
}

View file

@ -1,5 +1,5 @@
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
import { SecureStorage, type EncryptedData } from '@/lib/crypto/encryption'
import { SecureStorage } from '@/lib/crypto/encryption'
import { bytesToHex, hexToBytes } from '@/lib/utils/crypto'
export interface NostrIdentity {

View file

@ -0,0 +1,233 @@
// Notification manager that integrates Nostr events with push notifications
import { pushService, type NotificationPayload } from './push'
import { configUtils } from '@/lib/config'
import type { NostrNote } from '@/lib/nostr/client'
export interface NotificationOptions {
enabled: boolean
adminAnnouncements: boolean
mentions: boolean
replies: boolean
sound: boolean
}
export class NotificationManager {
private static instance: NotificationManager
private options: NotificationOptions = {
enabled: true,
adminAnnouncements: true,
mentions: true,
replies: false,
sound: true
}
static getInstance(): NotificationManager {
if (!this.instance) {
this.instance = new NotificationManager()
}
return this.instance
}
constructor() {
this.loadOptions()
}
// Load notification options from localStorage
private loadOptions(): void {
try {
const stored = localStorage.getItem('notification-options')
if (stored) {
this.options = { ...this.options, ...JSON.parse(stored) }
}
} catch (error) {
console.warn('Failed to load notification options:', error)
}
}
// Save notification options to localStorage
private saveOptions(): void {
try {
localStorage.setItem('notification-options', JSON.stringify(this.options))
} catch (error) {
console.warn('Failed to save notification options:', error)
}
}
// Get current options
getOptions(): NotificationOptions {
return { ...this.options }
}
// Update options
updateOptions(newOptions: Partial<NotificationOptions>): void {
this.options = { ...this.options, ...newOptions }
this.saveOptions()
}
// Check if notifications should be sent for a note
shouldNotify(note: NostrNote, userPubkey?: string): boolean {
if (!this.options.enabled) return false
// Admin announcements
if (this.options.adminAnnouncements && configUtils.isAdminPubkey(note.pubkey)) {
return true
}
// Mentions (if user is mentioned in the note)
if (this.options.mentions && userPubkey && note.mentions.includes(userPubkey)) {
return true
}
// Replies (if it's a reply to user's note)
if (this.options.replies && userPubkey && note.isReply && note.replyTo) {
// We'd need to check if the reply is to the user's note
// This would require additional context about the user's notes
return false
}
return false
}
// Create notification payload from Nostr note
private createNotificationPayload(note: NostrNote): NotificationPayload {
const isAdmin = configUtils.isAdminPubkey(note.pubkey)
let title = 'New Note'
let body = note.content
let tag = 'nostr-note'
if (isAdmin) {
title = '🚨 Admin Announcement'
tag = 'admin-announcement'
} else if (note.isReply) {
title = 'Reply'
tag = 'reply'
} else if (note.mentions.length > 0) {
title = 'Mention'
tag = 'mention'
}
// Truncate long content
if (body.length > 100) {
body = body.slice(0, 100) + '...'
}
return {
title,
body,
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
tag,
requireInteraction: isAdmin, // Admin announcements require interaction
data: {
noteId: note.id,
pubkey: note.pubkey,
isAdmin,
url: '/',
timestamp: note.created_at
},
actions: isAdmin ? [
{
action: 'view',
title: 'View'
},
{
action: 'dismiss',
title: 'Dismiss'
}
] : [
{
action: 'view',
title: 'View'
}
]
}
}
// Send notification for a Nostr note
async notifyForNote(note: NostrNote, userPubkey?: string): Promise<void> {
try {
if (!this.shouldNotify(note, userPubkey)) {
return
}
if (!pushService.isSupported()) {
console.warn('Push notifications not supported')
return
}
const isSubscribed = await pushService.isSubscribed()
if (!isSubscribed) {
console.log('User not subscribed to push notifications')
return
}
const payload = this.createNotificationPayload(note)
await pushService.showLocalNotification(payload)
console.log('Notification sent for note:', note.id)
} catch (error) {
console.error('Failed to send notification for note:', error)
}
}
// Send test notification
async sendTestNotification(): Promise<void> {
const testPayload: NotificationPayload = {
title: '🚨 Test Admin Announcement',
body: 'This is a test notification to verify push notifications are working correctly.',
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
tag: 'test-notification',
requireInteraction: true,
data: {
url: '/',
type: 'test',
timestamp: Date.now()
},
actions: [
{
action: 'view',
title: 'View App'
},
{
action: 'dismiss',
title: 'Dismiss'
}
]
}
await pushService.showLocalNotification(testPayload)
}
// Handle background notification processing
async processBackgroundNote(noteData: any): Promise<void> {
// This would be called from the service worker
// when receiving push notifications from a backend
try {
const payload = this.createNotificationPayload(noteData)
// Show notification via service worker
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready
await registration.showNotification(payload.title, payload)
}
} catch (error) {
console.error('Failed to process background notification:', error)
}
}
// Check if user has denied notifications
isBlocked(): boolean {
return pushService.getPermission() === 'denied'
}
// Check if notifications are enabled and configured
isConfigured(): boolean {
return pushService.isSupported() && configUtils.hasPushConfig()
}
}
// Export singleton instance
export const notificationManager = NotificationManager.getInstance()

View file

@ -0,0 +1,220 @@
// Push notification service for browser push API
import { config, configUtils } from '@/lib/config'
export interface PushSubscriptionData {
endpoint: string
keys: {
p256dh: string
auth: string
}
}
export interface NotificationPayload {
title: string
body: string
icon?: string
badge?: string
data?: Record<string, any>
tag?: string
requireInteraction?: boolean
actions?: Array<{
action: string
title: string
icon?: string
}>
}
export class PushNotificationService {
private static instance: PushNotificationService
static getInstance(): PushNotificationService {
if (!this.instance) {
this.instance = new PushNotificationService()
}
return this.instance
}
// Check if push notifications are supported
isSupported(): boolean {
return 'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
}
// Check if push notifications are configured
isConfigured(): boolean {
return configUtils.hasPushConfig()
}
// Get current notification permission
getPermission(): NotificationPermission {
return Notification.permission
}
// Request notification permission
async requestPermission(): Promise<NotificationPermission> {
if (!this.isSupported()) {
throw new Error('Push notifications are not supported')
}
const permission = await Notification.requestPermission()
console.log('Notification permission:', permission)
return permission
}
// Convert Uint8Array to base64 URL-safe string
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
// Subscribe to push notifications
async subscribe(): Promise<PushSubscriptionData> {
if (!this.isSupported()) {
throw new Error('Push notifications are not supported')
}
if (!this.isConfigured()) {
throw new Error('Push notifications are not configured. Missing VAPID key.')
}
if (this.getPermission() !== 'granted') {
const permission = await this.requestPermission()
if (permission !== 'granted') {
throw new Error('Push notification permission denied')
}
}
try {
const registration = await navigator.serviceWorker.ready
// Check if already subscribed
const existingSubscription = await registration.pushManager.getSubscription()
if (existingSubscription) {
return this.subscriptionToData(existingSubscription)
}
// Create new subscription
const applicationServerKey = this.urlBase64ToUint8Array(config.push.vapidPublicKey)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
const subscriptionData = this.subscriptionToData(subscription)
console.log('Push subscription created:', subscriptionData)
return subscriptionData
} catch (error) {
console.error('Failed to subscribe to push notifications:', error)
throw error
}
}
// Unsubscribe from push notifications
async unsubscribe(): Promise<void> {
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
console.log('Push subscription removed')
}
} catch (error) {
console.error('Failed to unsubscribe from push notifications:', error)
throw error
}
}
// Get current subscription
async getSubscription(): Promise<PushSubscriptionData | null> {
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
return this.subscriptionToData(subscription)
}
return null
} catch (error) {
console.error('Failed to get push subscription:', error)
return null
}
}
// Convert PushSubscription to our data format
private subscriptionToData(subscription: PushSubscription): PushSubscriptionData {
const keys = subscription.getKey('p256dh')
const auth = subscription.getKey('auth')
if (!keys || !auth) {
throw new Error('Failed to get subscription keys')
}
return {
endpoint: subscription.endpoint,
keys: {
p256dh: btoa(String.fromCharCode(...new Uint8Array(keys))),
auth: btoa(String.fromCharCode(...new Uint8Array(auth)))
}
}
}
// Show a local notification (for testing)
async showLocalNotification(payload: NotificationPayload): Promise<void> {
if (!this.isSupported()) {
throw new Error('Notifications are not supported')
}
if (this.getPermission() !== 'granted') {
await this.requestPermission()
}
if (this.getPermission() !== 'granted') {
throw new Error('Notification permission denied')
}
// Send message to service worker to show notification
const registration = await navigator.serviceWorker.ready
if (registration.active) {
registration.active.postMessage({
type: 'SHOW_NOTIFICATION',
payload
})
} else {
// Fallback: show browser notification directly
new Notification(payload.title, {
body: payload.body,
icon: payload.icon || '/pwa-192x192.png',
badge: payload.badge || '/pwa-192x192.png',
tag: payload.tag,
requireInteraction: payload.requireInteraction,
data: payload.data
})
}
}
// Check if user is subscribed to push notifications
async isSubscribed(): Promise<boolean> {
const subscription = await this.getSubscription()
return subscription !== null
}
}
// Export singleton instance
export const pushService = PushNotificationService.getInstance()

View file

@ -1,9 +1,11 @@
<template>
<div class="container py-8">
<div class="container py-8 space-y-6">
<NotificationPermission auto-show />
<NostrFeed feed-type="announcements" />
</div>
</template>
<script setup lang="ts">
import NostrFeed from '@/components/nostr/NostrFeed.vue'
import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
</script>

View file

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { NostrClient } from '@/lib/nostr/client'
import { config } from '@/lib/config'
import { pushService, type PushSubscriptionData } from '@/lib/notifications/push'
// Define an interface for the account object
interface NostrAccount {
@ -19,6 +20,10 @@ export const useNostrStore = defineStore('nostr', () => {
const relayUrls = ref<string[]>(config.nostr.relays)
const account = ref<NostrAccount | null>(null)
// Push notifications
const pushSubscription = ref<PushSubscriptionData | null>(null)
const notificationsEnabled = ref(false)
// Singleton client instance
let client: NostrClient | null = null
@ -77,6 +82,78 @@ export const useNostrStore = defineStore('nostr', () => {
account.value = nostrAccount
}
// Push notification management
async function enablePushNotifications(): Promise<PushSubscriptionData> {
try {
const subscription = await pushService.subscribe()
pushSubscription.value = subscription
notificationsEnabled.value = true
// Store subscription in localStorage for persistence
localStorage.setItem('push-subscription', JSON.stringify(subscription))
localStorage.setItem('notifications-enabled', 'true')
return subscription
} catch (error) {
console.error('Failed to enable push notifications:', error)
throw error
}
}
async function disablePushNotifications(): Promise<void> {
try {
await pushService.unsubscribe()
pushSubscription.value = null
notificationsEnabled.value = false
// Remove from localStorage
localStorage.removeItem('push-subscription')
localStorage.removeItem('notifications-enabled')
} catch (error) {
console.error('Failed to disable push notifications:', error)
throw error
}
}
async function checkPushNotificationStatus(): Promise<void> {
try {
// Check localStorage first
const storedEnabled = localStorage.getItem('notifications-enabled') === 'true'
const storedSubscription = localStorage.getItem('push-subscription')
if (storedEnabled && storedSubscription) {
pushSubscription.value = JSON.parse(storedSubscription)
notificationsEnabled.value = true
}
// Verify with push service
const currentSubscription = await pushService.getSubscription()
if (currentSubscription) {
pushSubscription.value = currentSubscription
notificationsEnabled.value = true
} else if (storedEnabled) {
// Stored state says enabled but no actual subscription - clear stored state
await disablePushNotifications()
}
} catch (error) {
console.error('Failed to check push notification status:', error)
}
}
// Send test notification
async function sendTestNotification(): Promise<void> {
await pushService.showLocalNotification({
title: '🚨 Test Admin Announcement',
body: 'This is a test notification to verify push notifications are working correctly.',
icon: '/pwa-192x192.png',
tag: 'test-notification',
data: {
url: '/',
type: 'admin-announcement'
}
})
}
return {
// State
isConnected,
@ -84,6 +161,8 @@ export const useNostrStore = defineStore('nostr', () => {
error,
relayUrls,
account,
pushSubscription,
notificationsEnabled,
// Actions
connect,
@ -92,5 +171,9 @@ export const useNostrStore = defineStore('nostr', () => {
setConnected,
setRelayUrls,
setAccount,
enablePushNotifications,
disablePushNotifications,
checkPushNotificationStatus,
sendTestNotification,
}
})

View file

@ -17,9 +17,10 @@ export default defineConfig(({ mode }) => ({
devOptions: {
enabled: true
},
strategies: 'injectManifest',
srcDir: 'public',
filename: 'sw.js',
workbox: {
clientsClaim: true,
skipWaiting: true,
globPatterns: [
'**/*.{js,css,html,ico,png,svg}'
]