Squash merge nostrfeed-localStorage into ario

This commit is contained in:
padreug 2025-08-03 11:09:42 +02:00
parent 5e6bc32f02
commit 5ceb12ca3b
5 changed files with 432 additions and 121 deletions

View file

@ -1,79 +1,82 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import type { NostrNote } from '@/lib/nostr/client'
import { useNostr } from '@/composables/useNostr'
import { computed, onMounted, onUnmounted } from 'vue'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
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'
import { config } from '@/lib/config'
import { useNostrFeedPreloader } from '@/composables/useNostrFeedPreloader'
const props = defineProps<{
relays?: string[]
feedType?: 'all' | 'announcements' | 'events' | 'general'
}>()
const notes = ref<NostrNote[]>([])
const isLoading = ref(true)
const error = ref<Error | null>(null)
const feedPreloader = useNostrFeedPreloader({
relays: props.relays,
feedType: props.feedType,
limit: 50,
includeReplies: false
})
const { getClient } = useNostr(props.relays ? { relays: props.relays } : undefined)
const nostrStore = useNostrStore()
const {
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
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 {
return adminPubkeys.includes(pubkey)
}
@ -104,62 +107,22 @@ 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)
onMounted(() => {
// Only load feed if it hasn't been preloaded
if (needsToLoadFeed.value) {
console.log('Feed not preloaded, loading now...')
loadFeed()
} else if (isPreloaded.value) {
console.log('Feed was preloaded, subscribing to updates...')
// Subscribe to real-time updates if feed was preloaded
subscribeToFeedUpdates()
} else {
console.log('Feed data is ready, no additional loading needed')
}
}
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(() => {
if (unsubscribe) {
unsubscribe()
}
cleanup()
})
function formatDate(timestamp: number): string {
@ -179,15 +142,15 @@ function formatDate(timestamp: number): string {
</CardHeader>
<CardContent>
<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>
<div v-else-if="error" class="text-center py-8 text-destructive">
{{ error.message }}
<div v-else-if="error || preloadError" class="text-center py-8 text-destructive">
{{ (error || preloadError)?.message }}
</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">
<p>No admin pubkeys configured</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>
</div>
<div v-else class="space-y-4">
<div v-else-if="notes.length > 0" class="space-y-4">
<Card
v-for="note in notes"
:key="note.id"
@ -233,10 +196,11 @@ function formatDate(timestamp: number): string {
</CardContent>
<CardFooter class="flex justify-between">
<button
class="text-sm text-primary hover:underline"
:disabled="isLoading"
@click="loadNotes"
class="text-sm text-primary hover:underline flex items-center gap-2"
:disabled="isLoading || isPreloading"
@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
</button>
<span v-if="adminPubkeys.length > 0" class="text-xs text-muted-foreground">