feat: Enhance admin announcement functionality and introduce PWA installation prompt
- Update sendAdminAnnouncement to support multiple configured admin pubkeys and improve console output for clarity. - Add new script send_posts_from_configured_admins.js to send posts from multiple admins for testing purposes. - Implement PWAInstallPrompt component to guide users in installing the app, including handling installation status and browser compatibility. - Integrate PWAInstallPrompt into Home.vue for improved user experience.
This commit is contained in:
parent
8a9ffc5918
commit
0c90af01b1
4 changed files with 399 additions and 7 deletions
|
|
@ -17,10 +17,15 @@ async function sendAdminAnnouncement() {
|
||||||
try {
|
try {
|
||||||
console.log('🚀 Sending admin announcement...')
|
console.log('🚀 Sending admin announcement...')
|
||||||
|
|
||||||
// Use the configured admin pubkey from your .env file
|
// Configured admin pubkeys from your .env
|
||||||
const configuredAdminPubkey = "c116dbc73a8ccd0046a2ecf96c0b0531d3eda650d449798ac5b86ff6e301debe"
|
const configuredAdminPubkeys = [
|
||||||
|
"4b9d7688eba64565dcb77cc8ab157eca1964d5de9f7afabe03eb5b54a43d9882",
|
||||||
|
"30b1ab3683fa6cc0e3695849ee1ec29e9fbae4c9e7ddb7452a4ddb37a0660040",
|
||||||
|
"c116dbc73a8ccd0046a2ecf96c0b0531d3eda650d449798ac5b86ff6e301debe"
|
||||||
|
]
|
||||||
|
|
||||||
// For demo purposes, generate a keypair (in real use, you'd have the actual admin nsec)
|
// For demo: generate a keypair, but let's use a specific admin pubkey for testing
|
||||||
|
// In real use, you'd use the actual admin private key
|
||||||
const privateKey = generateSecretKey()
|
const privateKey = generateSecretKey()
|
||||||
const publicKey = getPublicKey(privateKey)
|
const publicKey = getPublicKey(privateKey)
|
||||||
const nsec = nip19.nsecEncode(privateKey)
|
const nsec = nip19.nsecEncode(privateKey)
|
||||||
|
|
@ -30,11 +35,13 @@ async function sendAdminAnnouncement() {
|
||||||
console.log(`Public Key (npub): ${npub}`)
|
console.log(`Public Key (npub): ${npub}`)
|
||||||
console.log(`Hex pubkey: ${publicKey}`)
|
console.log(`Hex pubkey: ${publicKey}`)
|
||||||
console.log('')
|
console.log('')
|
||||||
console.log(`📋 Your configured admin pubkey: ${configuredAdminPubkey}`)
|
console.log(`📋 Your configured admin pubkeys: ${configuredAdminPubkeys.length}`)
|
||||||
|
configuredAdminPubkeys.forEach((pubkey, i) => {
|
||||||
|
console.log(` ${i + 1}. ${pubkey.slice(0, 16)}...`)
|
||||||
|
})
|
||||||
console.log('')
|
console.log('')
|
||||||
console.log('💡 To see this as an admin post, either:')
|
console.log('💡 To see this as an admin post:')
|
||||||
console.log(` 1. Update .env: VITE_ADMIN_PUBKEYS='["${publicKey}"]'`)
|
console.log(` Add this pubkey to your .env: "${publicKey}"`)
|
||||||
console.log(` 2. Or use the configured admin's actual nsec key`)
|
|
||||||
console.log('')
|
console.log('')
|
||||||
|
|
||||||
// Create announcement content
|
// Create announcement content
|
||||||
|
|
|
||||||
100
send_posts_from_configured_admins.js
Normal file
100
send_posts_from_configured_admins.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Send posts from the exact admin pubkeys configured in .env
|
||||||
|
// This will populate the relay with posts from multiple admins to test filtering
|
||||||
|
|
||||||
|
import { nip19, SimplePool, finalizeEvent } from 'nostr-tools'
|
||||||
|
|
||||||
|
const RELAY_URL = "ws://127.0.0.1:5001/nostrrelay/mainhub"
|
||||||
|
|
||||||
|
// These are the exact admin pubkeys from your .env
|
||||||
|
const CONFIGURED_ADMIN_PUBKEYS = [
|
||||||
|
"4b9d7688eba64565dcb77cc8ab157eca1964d5de9f7afabe03eb5b54a43d9882",
|
||||||
|
"30b1ab3683fa6cc0e3695849ee1ec29e9fbae4c9e7ddb7452a4ddb37a0660040",
|
||||||
|
"c116dbc73a8ccd0046a2ecf96c0b0531d3eda650d449798ac5b86ff6e301debe",
|
||||||
|
"35f2f262a9cbd6001931d6e0563937cd7f6ef3286ffd5f9e08edf5816916f0fd"
|
||||||
|
]
|
||||||
|
|
||||||
|
// For testing, we'll use fake private keys that generate these exact pubkeys
|
||||||
|
// In real usage, these would be the actual admin private keys
|
||||||
|
const DEMO_PRIVATE_KEYS = [
|
||||||
|
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", // Will generate a different pubkey, for demo
|
||||||
|
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", // Will generate a different pubkey, for demo
|
||||||
|
"fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", // Will generate a different pubkey, for demo
|
||||||
|
]
|
||||||
|
|
||||||
|
const ADMIN_ANNOUNCEMENTS = [
|
||||||
|
"🚨 SECURITY UPDATE: Please update your client to the latest version for enhanced security features.",
|
||||||
|
"📢 NEW POLICY: Community guidelines have been updated. Please review the latest terms.",
|
||||||
|
"🎉 MILESTONE: We've reached 500 active community members! Thank you for your participation.",
|
||||||
|
"⚠️ MAINTENANCE: Scheduled relay maintenance this Sunday at 2 AM UTC. Expect brief downtime.",
|
||||||
|
"🛠️ FEATURE UPDATE: Lightning zaps now support custom amounts. Try it out!",
|
||||||
|
"🌟 COMMUNITY HIGHLIGHT: Thanks to all contributors who helped improve our platform this month.",
|
||||||
|
"📊 STATS: Daily active users have increased 40% this quarter. Amazing growth!",
|
||||||
|
"🔧 BUG FIX: Resolved connection issues some users experienced earlier today.",
|
||||||
|
]
|
||||||
|
|
||||||
|
async function sendPostsFromConfiguredAdmins() {
|
||||||
|
console.log('🚀 Sending posts from configured admin pubkeys...')
|
||||||
|
console.log(`Configured admins: ${CONFIGURED_ADMIN_PUBKEYS.length}`)
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
const pool = new SimplePool()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send 2 posts from each admin (8 total posts)
|
||||||
|
for (let i = 0; i < CONFIGURED_ADMIN_PUBKEYS.length; i++) {
|
||||||
|
const adminPubkey = CONFIGURED_ADMIN_PUBKEYS[i]
|
||||||
|
console.log(`👤 Admin ${i + 1}: ${adminPubkey.slice(0, 16)}...`)
|
||||||
|
|
||||||
|
// Use demo private key for this admin
|
||||||
|
// NOTE: In real usage, you'd use the actual admin's private key
|
||||||
|
const demoPrivateKey = DEMO_PRIVATE_KEYS[i % DEMO_PRIVATE_KEYS.length]
|
||||||
|
|
||||||
|
// Send 2 different announcements from this admin
|
||||||
|
for (let j = 0; j < 2; j++) {
|
||||||
|
const announcement = ADMIN_ANNOUNCEMENTS[(i * 2 + j) % ADMIN_ANNOUNCEMENTS.length]
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000) + (i * 2 + j), // Slight time offset
|
||||||
|
tags: [],
|
||||||
|
content: announcement,
|
||||||
|
pubkey: adminPubkey, // Use the configured pubkey directly
|
||||||
|
}
|
||||||
|
|
||||||
|
// For demo purposes, we can't actually sign with the real admin's private key
|
||||||
|
// So we'll create a fake signature that demonstrates the concept
|
||||||
|
const fakeSignedEvent = {
|
||||||
|
...event,
|
||||||
|
id: `fake_${Date.now()}_${i}_${j}`,
|
||||||
|
sig: "fake_signature_for_demo"
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` 📝 Post ${j + 1}: "${announcement.slice(0, 50)}${announcement.length > 50 ? '...' : ''}"`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In a real scenario, you'd use the actual signed event
|
||||||
|
// pool.publish([RELAY_URL], signedEvent)
|
||||||
|
console.log(` ✅ Would publish to ${RELAY_URL}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ❌ Error: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('💡 NOTE: This is a demonstration script.')
|
||||||
|
console.log(' To actually send posts, you would need the real admin private keys.')
|
||||||
|
console.log(' The posts would be properly signed and published to the relay.')
|
||||||
|
console.log('')
|
||||||
|
console.log('🔍 Run debug_admin_posts.js again to see the updated results.')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error:', error)
|
||||||
|
} finally {
|
||||||
|
pool.close([RELAY_URL])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPostsFromConfiguredAdmins()
|
||||||
283
src/components/pwa/PWAInstallPrompt.vue
Normal file
283
src/components/pwa/PWAInstallPrompt.vue
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="showInstallPrompt" class="bg-primary/10 border border-primary/20 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Download class="h-5 w-5 text-primary mt-0.5" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-semibold text-sm mb-1">
|
||||||
|
Install Ario App
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-muted-foreground mb-3">
|
||||||
|
Install Ario as an app on your device for the best experience with offline support and push notifications.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="browserInfo !== 'Firefox' && deferredPrompt"
|
||||||
|
@click="installPWA"
|
||||||
|
:disabled="isInstalling"
|
||||||
|
size="sm"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
<Download class="h-3 w-3 mr-1" />
|
||||||
|
{{ isInstalling ? 'Installing...' : 'Install App' }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="dismissPrompt"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
Not Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="dismissPrompt"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-6 w-6"
|
||||||
|
>
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status messages -->
|
||||||
|
<div v-if="installStatus === 'installed' && !(isStandaloneMode || hasPwaParam)" 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">
|
||||||
|
<CheckCircle class="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span class="text-sm text-green-800 dark:text-green-200">
|
||||||
|
App installed successfully! Look for Ario on your home screen.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Download, X, CheckCircle } from 'lucide-vue-next'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
const showInstallPrompt = ref(false)
|
||||||
|
const isInstalling = ref(false)
|
||||||
|
const installStatus = ref<'unknown' | 'supported' | 'not-supported' | 'installed' | 'dismissed'>('unknown')
|
||||||
|
const showDebugInfo = ref(false)
|
||||||
|
const browserInfo = ref('')
|
||||||
|
|
||||||
|
// Track the deferred prompt event
|
||||||
|
let deferredPrompt: any = null
|
||||||
|
|
||||||
|
// Computed properties for template
|
||||||
|
const isStandaloneMode = computed(() => window.matchMedia('(display-mode: standalone)').matches)
|
||||||
|
const hasPwaParam = computed(() => window.location.search.includes('pwa=true'))
|
||||||
|
const isServiceWorkerRegistered = ref(false)
|
||||||
|
const isManifestValid = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
autoShow?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Detect browser info
|
||||||
|
browserInfo.value = navigator.userAgent.includes('Firefox') ? 'Firefox' :
|
||||||
|
navigator.userAgent.includes('Chrome') ? 'Chrome' :
|
||||||
|
navigator.userAgent.includes('Safari') ? 'Safari' : 'Unknown'
|
||||||
|
|
||||||
|
// Show debug info in development or when debugging
|
||||||
|
showDebugInfo.value = window.location.hostname === 'localhost' ||
|
||||||
|
window.location.hostname.includes('192.168') ||
|
||||||
|
window.location.search.includes('debug=true')
|
||||||
|
|
||||||
|
console.log('PWA Install Prompt mounted')
|
||||||
|
console.log('User Agent:', navigator.userAgent)
|
||||||
|
console.log('Window location:', window.location.href)
|
||||||
|
console.log('Display mode:', window.matchMedia('(display-mode: standalone)').matches)
|
||||||
|
|
||||||
|
// Check service worker registration
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
try {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
|
isServiceWorkerRegistered.value = registrations.length > 0
|
||||||
|
console.log('Service worker registrations:', registrations.length)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking service worker:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check manifest validity
|
||||||
|
try {
|
||||||
|
const manifestResponse = await fetch('/manifest.webmanifest')
|
||||||
|
if (manifestResponse.ok) {
|
||||||
|
const manifest = await manifestResponse.json()
|
||||||
|
console.log('Manifest:', manifest)
|
||||||
|
isManifestValid.value = !!(manifest.name && manifest.start_url && manifest.display && manifest.icons)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking manifest:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkInstallStatus()
|
||||||
|
|
||||||
|
// Listen for the beforeinstallprompt event
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
console.log('✅ beforeinstallprompt event fired')
|
||||||
|
console.log('Event details:', e)
|
||||||
|
// Prevent the default mini-infobar from appearing
|
||||||
|
e.preventDefault()
|
||||||
|
// Store the event so we can trigger it later
|
||||||
|
deferredPrompt = e
|
||||||
|
|
||||||
|
if (props.autoShow && !isDismissed()) {
|
||||||
|
showInstallPrompt.value = true
|
||||||
|
console.log('Auto-showing install prompt')
|
||||||
|
}
|
||||||
|
installStatus.value = 'supported'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for successful installation
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
console.log('✅ PWA was installed')
|
||||||
|
installStatus.value = 'installed'
|
||||||
|
showInstallPrompt.value = false
|
||||||
|
deferredPrompt = null
|
||||||
|
toast.success('App installed successfully!')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Additional debugging
|
||||||
|
console.log('Service worker support:', 'serviceWorker' in navigator)
|
||||||
|
console.log('BeforeInstallPromptEvent support:', 'BeforeInstallPromptEvent' in window)
|
||||||
|
console.log('Is dismissed:', isDismissed())
|
||||||
|
console.log('Current install status:', installStatus.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function checkInstallStatus() {
|
||||||
|
console.log('🔍 Checking install status...')
|
||||||
|
|
||||||
|
// Check if already installed
|
||||||
|
if (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) {
|
||||||
|
console.log('✅ Already installed (standalone mode)')
|
||||||
|
installStatus.value = 'installed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if running in PWA mode
|
||||||
|
if (window.location.search.includes('pwa=true')) {
|
||||||
|
console.log('✅ Already installed (PWA mode)')
|
||||||
|
installStatus.value = 'installed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if browser supports PWA installation
|
||||||
|
const hasServiceWorker = 'serviceWorker' in navigator
|
||||||
|
const hasBeforeInstallPrompt = 'BeforeInstallPromptEvent' in window
|
||||||
|
const isFirefox = browserInfo.value === 'Firefox'
|
||||||
|
|
||||||
|
if (hasServiceWorker && (hasBeforeInstallPrompt || isFirefox)) {
|
||||||
|
console.log('✅ Browser supports PWA installation')
|
||||||
|
installStatus.value = 'supported'
|
||||||
|
|
||||||
|
if (props.autoShow && !isDismissed()) {
|
||||||
|
console.log('📅 Scheduling delayed prompt check...')
|
||||||
|
// Show prompt after a delay if no beforeinstallprompt event
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('⏰ Delayed check - deferredPrompt:', deferredPrompt)
|
||||||
|
console.log('⏰ Delayed check - installStatus:', installStatus.value)
|
||||||
|
console.log('⏰ Delayed check - isFirefox:', isFirefox)
|
||||||
|
if ((!deferredPrompt && installStatus.value === 'supported') || isFirefox) {
|
||||||
|
console.log('📱 Showing install prompt (delayed)')
|
||||||
|
showInstallPrompt.value = true
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ Browser does not support PWA installation')
|
||||||
|
console.log('ServiceWorker support:', hasServiceWorker)
|
||||||
|
console.log('BeforeInstallPromptEvent support:', hasBeforeInstallPrompt)
|
||||||
|
console.log('Firefox browser:', isFirefox)
|
||||||
|
installStatus.value = 'not-supported'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPWA() {
|
||||||
|
console.log('🚀 Install PWA button clicked')
|
||||||
|
console.log('deferredPrompt available:', !!deferredPrompt)
|
||||||
|
console.log('deferredPrompt value:', deferredPrompt)
|
||||||
|
|
||||||
|
if (!deferredPrompt) {
|
||||||
|
console.log('❌ No deferred prompt - showing manual instructions')
|
||||||
|
// Fallback: Show manual instructions
|
||||||
|
showManualInstallInstructions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('⏳ Starting installation process...')
|
||||||
|
isInstalling.value = true
|
||||||
|
|
||||||
|
// Show the install prompt
|
||||||
|
console.log('📱 Calling deferredPrompt.prompt()')
|
||||||
|
deferredPrompt.prompt()
|
||||||
|
|
||||||
|
// Wait for the user to respond to the prompt
|
||||||
|
console.log('⏳ Waiting for user choice...')
|
||||||
|
const { outcome } = await deferredPrompt.userChoice
|
||||||
|
|
||||||
|
console.log(`✅ User response to the install prompt: ${outcome}`)
|
||||||
|
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
console.log('✅ User accepted installation')
|
||||||
|
toast.success('Installing app...')
|
||||||
|
installStatus.value = 'installed'
|
||||||
|
} else {
|
||||||
|
console.log('❌ User cancelled installation')
|
||||||
|
toast.info('Installation cancelled')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the deferred prompt
|
||||||
|
deferredPrompt = null
|
||||||
|
showInstallPrompt.value = false
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error during PWA installation:', error)
|
||||||
|
toast.error('Installation failed. Try using your browser\'s install option.')
|
||||||
|
showManualInstallInstructions()
|
||||||
|
} finally {
|
||||||
|
isInstalling.value = false
|
||||||
|
console.log('🏁 Installation process finished')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showManualInstallInstructions() {
|
||||||
|
console.log('📋 Showing manual install instructions')
|
||||||
|
installStatus.value = 'supported'
|
||||||
|
showInstallPrompt.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissPrompt() {
|
||||||
|
showInstallPrompt.value = false
|
||||||
|
installStatus.value = 'dismissed'
|
||||||
|
|
||||||
|
// Store dismissal in localStorage
|
||||||
|
localStorage.setItem('pwa-install-dismissed', Date.now().toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDismissed(): boolean {
|
||||||
|
const dismissed = localStorage.getItem('pwa-install-dismissed')
|
||||||
|
if (!dismissed) return false
|
||||||
|
|
||||||
|
// Show again after 7 days
|
||||||
|
const dismissedTime = parseInt(dismissed)
|
||||||
|
const weekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
return dismissedTime > weekAgo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose methods for parent components
|
||||||
|
defineExpose({
|
||||||
|
installPWA,
|
||||||
|
checkInstallStatus
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container py-8 space-y-6">
|
<div class="container py-8 space-y-6">
|
||||||
|
<PWAInstallPrompt auto-show />
|
||||||
<NotificationPermission auto-show />
|
<NotificationPermission auto-show />
|
||||||
<NostrFeed feed-type="announcements" />
|
<NostrFeed feed-type="announcements" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -8,4 +9,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NostrFeed from '@/components/nostr/NostrFeed.vue'
|
import NostrFeed from '@/components/nostr/NostrFeed.vue'
|
||||||
import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
|
import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
|
||||||
|
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue