Squash merge nostrfeed-localStorage into ario
This commit is contained in:
parent
5e6bc32f02
commit
5ceb12ca3b
5 changed files with 432 additions and 121 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import Navbar from '@/components/layout/Navbar.vue'
|
import Navbar from '@/components/layout/Navbar.vue'
|
||||||
import Footer from '@/components/layout/Footer.vue'
|
import Footer from '@/components/layout/Footer.vue'
|
||||||
import ConnectionStatus from '@/components/nostr/ConnectionStatus.vue'
|
|
||||||
import PasswordDialog from '@/components/nostr/PasswordDialog.vue'
|
import PasswordDialog from '@/components/nostr/PasswordDialog.vue'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import 'vue-sonner/style.css'
|
import 'vue-sonner/style.css'
|
||||||
|
|
@ -11,7 +11,7 @@ import { identity } from '@/composables/useIdentity'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
import { useNostrStore } from '@/stores/nostr'
|
||||||
|
|
||||||
const { isConnected, isConnecting, error, connect, disconnect } = useNostr()
|
const { connect, disconnect } = useNostr()
|
||||||
const nostrStore = useNostrStore()
|
const nostrStore = useNostrStore()
|
||||||
|
|
||||||
const showPasswordDialog = ref(false)
|
const showPasswordDialog = ref(false)
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,82 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
import type { NostrNote } from '@/lib/nostr/client'
|
|
||||||
import { useNostr } from '@/composables/useNostr'
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { Megaphone } from 'lucide-vue-next'
|
import { Megaphone } from 'lucide-vue-next'
|
||||||
import { config, configUtils } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
import { notificationManager } from '@/lib/notifications/manager'
|
import { useNostrFeedPreloader } from '@/composables/useNostrFeedPreloader'
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
feedType?: 'all' | 'announcements' | 'events' | 'general'
|
feedType?: 'all' | 'announcements' | 'events' | 'general'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const notes = ref<NostrNote[]>([])
|
const feedPreloader = useNostrFeedPreloader({
|
||||||
const isLoading = ref(true)
|
relays: props.relays,
|
||||||
const error = ref<Error | null>(null)
|
feedType: props.feedType,
|
||||||
|
limit: 50,
|
||||||
|
includeReplies: false
|
||||||
|
})
|
||||||
|
|
||||||
const { getClient } = useNostr(props.relays ? { relays: props.relays } : undefined)
|
const {
|
||||||
const nostrStore = useNostrStore()
|
notes,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isPreloading,
|
||||||
|
isPreloaded,
|
||||||
|
preloadError,
|
||||||
|
shouldShowLoading,
|
||||||
|
loadNotes,
|
||||||
|
connectToFeed,
|
||||||
|
subscribeToFeedUpdates,
|
||||||
|
cleanup
|
||||||
|
} = feedPreloader
|
||||||
|
|
||||||
|
// Check if we need to load feed data
|
||||||
|
const needsToLoadFeed = computed(() => {
|
||||||
|
return !isPreloaded.value &&
|
||||||
|
!isPreloading.value &&
|
||||||
|
notes.value.length === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadFeed = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Connecting to feed...')
|
||||||
|
await connectToFeed()
|
||||||
|
console.log('Connected to feed')
|
||||||
|
|
||||||
|
console.log('Loading feed...')
|
||||||
|
await loadNotes()
|
||||||
|
console.log('Feed loaded')
|
||||||
|
|
||||||
|
// Subscribe to real-time updates
|
||||||
|
subscribeToFeedUpdates()
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load feed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryLoadFeed = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Refreshing feed...')
|
||||||
|
await connectToFeed()
|
||||||
|
await loadNotes(true) // Pass true to indicate this is a refresh
|
||||||
|
console.log('Feed refreshed')
|
||||||
|
|
||||||
|
// Subscribe to real-time updates
|
||||||
|
subscribeToFeedUpdates()
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh feed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get admin/moderator pubkeys from centralized config
|
// Get admin/moderator pubkeys from centralized config
|
||||||
const adminPubkeys = config.nostr.adminPubkeys
|
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
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const client = getClient()
|
|
||||||
await client.connect()
|
|
||||||
|
|
||||||
// Configure fetch options based on feed type
|
|
||||||
const fetchOptions: Parameters<typeof client.fetchNotes>[0] = {
|
|
||||||
limit: 50,
|
|
||||||
includeReplies: false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by authors based on feed type
|
|
||||||
if (props.feedType === 'announcements') {
|
|
||||||
// Only show notes from admin/moderator pubkeys
|
|
||||||
if (adminPubkeys.length > 0) {
|
|
||||||
fetchOptions.authors = adminPubkeys
|
|
||||||
} else {
|
|
||||||
// If no admin pubkeys configured, show placeholder
|
|
||||||
notes.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else if (props.feedType === 'general') {
|
|
||||||
// Show notes from everyone EXCEPT admins (if configured)
|
|
||||||
// Note: This would require client-side filtering after fetch
|
|
||||||
// For now, we'll fetch all and filter
|
|
||||||
}
|
|
||||||
// 'all' and 'events' types get all notes (no author filter)
|
|
||||||
|
|
||||||
notes.value = await client.fetchNotes(fetchOptions)
|
|
||||||
|
|
||||||
// Client-side filtering for 'general' feed (exclude admin posts)
|
|
||||||
if (props.feedType === 'general' && adminPubkeys.length > 0) {
|
|
||||||
notes.value = notes.value.filter(note => !adminPubkeys.includes(note.pubkey))
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err : new Error('Failed to load notes')
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAdminPost(pubkey: string): boolean {
|
function isAdminPost(pubkey: string): boolean {
|
||||||
return adminPubkeys.includes(pubkey)
|
return adminPubkeys.includes(pubkey)
|
||||||
}
|
}
|
||||||
|
|
@ -104,62 +107,22 @@ function getFeedDescription(): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real-time subscription for new notes (especially admin announcements)
|
onMounted(() => {
|
||||||
let unsubscribe: (() => void) | null = null
|
// Only load feed if it hasn't been preloaded
|
||||||
|
if (needsToLoadFeed.value) {
|
||||||
async function startRealtimeSubscription() {
|
console.log('Feed not preloaded, loading now...')
|
||||||
try {
|
loadFeed()
|
||||||
const client = getClient()
|
} else if (isPreloaded.value) {
|
||||||
await client.connect()
|
console.log('Feed was preloaded, subscribing to updates...')
|
||||||
|
// Subscribe to real-time updates if feed was preloaded
|
||||||
// Subscribe to real-time notes
|
subscribeToFeedUpdates()
|
||||||
unsubscribe = client.subscribeToNotes((newNote) => {
|
} else {
|
||||||
// Only process notes newer than last seen
|
console.log('Feed data is ready, no additional loading needed')
|
||||||
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(() => {
|
onUnmounted(() => {
|
||||||
if (unsubscribe) {
|
cleanup()
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDate(timestamp: number): string {
|
function formatDate(timestamp: number): string {
|
||||||
|
|
@ -179,15 +142,15 @@ function formatDate(timestamp: number): string {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ScrollArea class="h-[600px] w-full pr-4">
|
<ScrollArea class="h-[600px] w-full pr-4">
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
<div v-if="shouldShowLoading" class="flex items-center justify-center py-8">
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="text-center py-8 text-destructive">
|
<div v-else-if="error || preloadError" class="text-center py-8 text-destructive">
|
||||||
{{ error.message }}
|
{{ (error || preloadError)?.message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="notes.length === 0" class="text-center py-8 text-muted-foreground">
|
<div v-else-if="notes.length === 0 && !isLoading && !isPreloading" class="text-center py-8 text-muted-foreground">
|
||||||
<div v-if="feedType === 'announcements' && adminPubkeys.length === 0" class="space-y-2">
|
<div v-if="feedType === 'announcements' && adminPubkeys.length === 0" class="space-y-2">
|
||||||
<p>No admin pubkeys configured</p>
|
<p>No admin pubkeys configured</p>
|
||||||
<p class="text-xs">Set VITE_ADMIN_PUBKEYS environment variable</p>
|
<p class="text-xs">Set VITE_ADMIN_PUBKEYS environment variable</p>
|
||||||
|
|
@ -195,7 +158,7 @@ function formatDate(timestamp: number): string {
|
||||||
<p v-else>No {{ feedType || 'notes' }} found</p>
|
<p v-else>No {{ feedType || 'notes' }} found</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else-if="notes.length > 0" class="space-y-4">
|
||||||
<Card
|
<Card
|
||||||
v-for="note in notes"
|
v-for="note in notes"
|
||||||
:key="note.id"
|
:key="note.id"
|
||||||
|
|
@ -233,10 +196,11 @@ function formatDate(timestamp: number): string {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter class="flex justify-between">
|
<CardFooter class="flex justify-between">
|
||||||
<button
|
<button
|
||||||
class="text-sm text-primary hover:underline"
|
class="text-sm text-primary hover:underline flex items-center gap-2"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading || isPreloading"
|
||||||
@click="loadNotes"
|
@click="retryLoadFeed"
|
||||||
>
|
>
|
||||||
|
<span v-if="isLoading || isPreloading" class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></span>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
<span v-if="adminPubkeys.length > 0" class="text-xs text-muted-foreground">
|
<span v-if="adminPubkeys.length > 0" class="text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
248
src/composables/useNostrFeed.ts
Normal file
248
src/composables/useNostrFeed.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { ref, readonly } from 'vue'
|
||||||
|
import type { NostrNote } from '@/lib/nostr/client'
|
||||||
|
import { useNostr } from '@/composables/useNostr'
|
||||||
|
import { useNostrStore } from '@/stores/nostr'
|
||||||
|
import { config as globalConfig } from '@/lib/config'
|
||||||
|
import { notificationManager } from '@/lib/notifications/manager'
|
||||||
|
|
||||||
|
export interface NostrFeedConfig {
|
||||||
|
relays?: string[]
|
||||||
|
feedType?: 'all' | 'announcements' | 'events' | 'general'
|
||||||
|
limit?: number
|
||||||
|
includeReplies?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNostrFeed(config: NostrFeedConfig = {}) {
|
||||||
|
const { getClient } = useNostr(config.relays ? { relays: config.relays } : undefined)
|
||||||
|
const nostrStore = useNostrStore()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const notes = ref<NostrNote[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
const isConnected = ref(false)
|
||||||
|
|
||||||
|
// Get admin/moderator pubkeys from centralized config
|
||||||
|
const adminPubkeys = globalConfig.nostr?.adminPubkeys || []
|
||||||
|
|
||||||
|
// Track last seen note timestamp to avoid duplicate notifications
|
||||||
|
let lastSeenTimestamp = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
// Load notes from localStorage immediately (synchronous)
|
||||||
|
const loadFromStorage = () => {
|
||||||
|
const storageKey = `nostr-feed-${config.feedType || 'all'}`
|
||||||
|
const storedNotes = localStorage.getItem(storageKey)
|
||||||
|
|
||||||
|
if (storedNotes) {
|
||||||
|
try {
|
||||||
|
const parsedNotes = JSON.parse(storedNotes) as NostrNote[]
|
||||||
|
notes.value = parsedNotes
|
||||||
|
console.log(`Loaded ${parsedNotes.length} notes from localStorage`)
|
||||||
|
|
||||||
|
// Update last seen timestamp from stored notes
|
||||||
|
if (notes.value.length > 0) {
|
||||||
|
lastSeenTimestamp = Math.max(lastSeenTimestamp, Math.max(...notes.value.map(note => note.created_at)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to parse stored notes:', err)
|
||||||
|
localStorage.removeItem(storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load notes from localStorage first, then fetch new ones
|
||||||
|
const loadNotes = async (isRefresh = false) => {
|
||||||
|
try {
|
||||||
|
// First, try to load from localStorage immediately (only if not refreshing)
|
||||||
|
if (!isRefresh) {
|
||||||
|
const hasStoredData = loadFromStorage()
|
||||||
|
|
||||||
|
// Only show loading if we don't have stored data
|
||||||
|
if (!hasStoredData) {
|
||||||
|
isLoading.value = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For refresh, always show loading
|
||||||
|
isLoading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
// Connect to Nostr
|
||||||
|
const client = getClient()
|
||||||
|
await client.connect()
|
||||||
|
isConnected.value = client.isConnected
|
||||||
|
|
||||||
|
if (!isConnected.value) {
|
||||||
|
throw new Error('Failed to connect to Nostr relays')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure fetch options based on feed type
|
||||||
|
const fetchOptions: Parameters<typeof client.fetchNotes>[0] = {
|
||||||
|
limit: config.limit || 50,
|
||||||
|
includeReplies: config.includeReplies || false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by authors based on feed type
|
||||||
|
if (config.feedType === 'announcements') {
|
||||||
|
if (adminPubkeys.length > 0) {
|
||||||
|
fetchOptions.authors = adminPubkeys
|
||||||
|
} else {
|
||||||
|
notes.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch new notes
|
||||||
|
const newNotes = await client.fetchNotes(fetchOptions)
|
||||||
|
|
||||||
|
// Client-side filtering for 'general' feed (exclude admin posts)
|
||||||
|
let filteredNotes = newNotes
|
||||||
|
if (config.feedType === 'general' && adminPubkeys.length > 0) {
|
||||||
|
filteredNotes = newNotes.filter(note => !adminPubkeys.includes(note.pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// For refresh, replace all notes. For normal load, merge with existing
|
||||||
|
if (isRefresh) {
|
||||||
|
notes.value = filteredNotes
|
||||||
|
} else {
|
||||||
|
// Merge with existing notes, avoiding duplicates
|
||||||
|
const existingIds = new Set(notes.value.map(note => note.id))
|
||||||
|
const uniqueNewNotes = filteredNotes.filter(note => !existingIds.has(note.id))
|
||||||
|
|
||||||
|
if (uniqueNewNotes.length > 0) {
|
||||||
|
// Add new notes to the beginning
|
||||||
|
notes.value.unshift(...uniqueNewNotes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit the array size to prevent memory issues
|
||||||
|
if (notes.value.length > 100) {
|
||||||
|
notes.value = notes.value.slice(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
const storageKey = `nostr-feed-${config.feedType || 'all'}`
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(notes.value))
|
||||||
|
console.log(`Loaded ${notes.value.length} notes`)
|
||||||
|
|
||||||
|
// Update last seen timestamp
|
||||||
|
if (notes.value.length > 0) {
|
||||||
|
lastSeenTimestamp = Math.max(lastSeenTimestamp, Math.max(...notes.value.map(note => note.created_at)))
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err : new Error('Failed to load notes')
|
||||||
|
console.error('Error loading notes:', err)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-time subscription for new notes
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
const subscribeToFeedUpdates = () => {
|
||||||
|
try {
|
||||||
|
const client = getClient()
|
||||||
|
|
||||||
|
// Subscribe to real-time notes
|
||||||
|
unsubscribe = client.subscribeToNotes((newNote) => {
|
||||||
|
// Only process notes newer than last seen
|
||||||
|
if (newNote.created_at > lastSeenTimestamp) {
|
||||||
|
// Check if note should be included based on feed type
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
const storageKey = `nostr-feed-${config.feedType || 'all'}`
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(notes.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification if appropriate (only for admin announcements when not in announcements feed)
|
||||||
|
if (config.feedType !== 'announcements' && adminPubkeys.includes(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldIncludeNote = (note: NostrNote): boolean => {
|
||||||
|
if (config.feedType === 'announcements') {
|
||||||
|
return adminPubkeys.length > 0 && adminPubkeys.includes(note.pubkey)
|
||||||
|
}
|
||||||
|
if (config.feedType === 'general' && adminPubkeys.length > 0) {
|
||||||
|
return !adminPubkeys.includes(note.pubkey)
|
||||||
|
}
|
||||||
|
// For other feed types, include all notes
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectToFeed = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Connecting to Nostr feed...')
|
||||||
|
const client = getClient()
|
||||||
|
await client.connect()
|
||||||
|
isConnected.value = client.isConnected
|
||||||
|
console.log('Connected to Nostr feed')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error connecting to feed:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnectFromFeed = () => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
unsubscribe = null
|
||||||
|
}
|
||||||
|
isConnected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFeedState = () => {
|
||||||
|
notes.value = []
|
||||||
|
error.value = null
|
||||||
|
isLoading.value = false
|
||||||
|
isConnected.value = false
|
||||||
|
lastSeenTimestamp = Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
disconnectFromFeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize by loading from storage immediately
|
||||||
|
loadFromStorage()
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
notes: readonly(notes),
|
||||||
|
isLoading: readonly(isLoading),
|
||||||
|
error: readonly(error),
|
||||||
|
isConnected: readonly(isConnected),
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadNotes,
|
||||||
|
connectToFeed,
|
||||||
|
disconnectFromFeed,
|
||||||
|
subscribeToFeedUpdates,
|
||||||
|
resetFeedState,
|
||||||
|
cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/composables/useNostrFeedPreloader.ts
Normal file
85
src/composables/useNostrFeedPreloader.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useNostrFeed, type NostrFeedConfig } from './useNostrFeed'
|
||||||
|
|
||||||
|
export function useNostrFeedPreloader(config: NostrFeedConfig = {}) {
|
||||||
|
const feed = useNostrFeed(config)
|
||||||
|
|
||||||
|
// Preload state
|
||||||
|
const isPreloading = ref(false)
|
||||||
|
const isPreloaded = ref(false)
|
||||||
|
const preloadError = ref<Error | null>(null)
|
||||||
|
|
||||||
|
// Check if feed data is available in localStorage
|
||||||
|
const hasStoredData = computed(() => {
|
||||||
|
const storageKey = `nostr-feed-${config.feedType || 'all'}`
|
||||||
|
const storedData = localStorage.getItem(storageKey)
|
||||||
|
return storedData !== null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if we should show loading (only if no cached data and actually loading)
|
||||||
|
const shouldShowLoading = computed(() => {
|
||||||
|
return (feed.isLoading.value || isPreloading.value) && feed.notes.value.length === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Preload feed data
|
||||||
|
const preloadFeed = async () => {
|
||||||
|
if (isPreloaded.value || isPreloading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isPreloading.value = true
|
||||||
|
preloadError.value = null
|
||||||
|
|
||||||
|
console.log('Preloading Nostr feed...')
|
||||||
|
|
||||||
|
// Connect and load notes
|
||||||
|
await feed.connectToFeed()
|
||||||
|
await feed.loadNotes()
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
feed.subscribeToFeedUpdates()
|
||||||
|
|
||||||
|
isPreloaded.value = true
|
||||||
|
console.log('Nostr feed preloaded successfully')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to preload Nostr feed:', error)
|
||||||
|
preloadError.value = error instanceof Error ? error : new Error('Failed to preload feed')
|
||||||
|
} finally {
|
||||||
|
isPreloading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset preload state
|
||||||
|
const resetPreload = () => {
|
||||||
|
isPreloaded.value = false
|
||||||
|
isPreloading.value = false
|
||||||
|
preloadError.value = null
|
||||||
|
feed.resetFeedState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-preload if we have stored data
|
||||||
|
const autoPreload = async () => {
|
||||||
|
if (hasStoredData.value && !isPreloaded.value && !isPreloading.value) {
|
||||||
|
await preloadFeed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isPreloading: computed(() => isPreloading.value),
|
||||||
|
isPreloaded: computed(() => isPreloaded.value),
|
||||||
|
preloadError: computed(() => preloadError.value),
|
||||||
|
hasStoredData,
|
||||||
|
shouldShowLoading,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
preloadFeed,
|
||||||
|
resetPreload,
|
||||||
|
autoPreload,
|
||||||
|
|
||||||
|
// Expose feed methods
|
||||||
|
...feed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,21 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
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'
|
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||||
|
import { useNostrFeedPreloader } from '@/composables/useNostrFeedPreloader'
|
||||||
|
|
||||||
|
// Preload the announcements feed for better UX
|
||||||
|
const feedPreloader = useNostrFeedPreloader({
|
||||||
|
feedType: 'announcements',
|
||||||
|
limit: 50,
|
||||||
|
includeReplies: false
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Auto-preload if we have stored data
|
||||||
|
await feedPreloader.autoPreload()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue