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:
parent
6c1caac84b
commit
c05f40f1ec
17 changed files with 1316 additions and 13 deletions
213
src/components/notifications/NotificationPermission.vue
Normal file
213
src/components/notifications/NotificationPermission.vue
Normal 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>
|
||||
222
src/components/notifications/NotificationSettings.vue
Normal file
222
src/components/notifications/NotificationSettings.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue