feat(nostr): Implement Nostr Feed with real-time note fetching

- Add NostrFeed component to display Nostr network notes
- Integrate date-fns for human-readable timestamp formatting
- Enhance NostrClient with fetchNotes and subscribeToNotes methods
- Implement loading, error, and empty state handling
- Add scrollable card-based UI for note display
- Configure dynamic relay selection with fallback to environment variables
This commit is contained in:
padreug 2025-03-09 16:57:22 +01:00
parent 68d6001880
commit 00f4bfa583
5 changed files with 195 additions and 3 deletions

11
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@vueuse/head": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"fuse.js": "^7.0.0",
"lucide-vue-next": "^0.474.0",
"nostr-tools": "^2.10.4",
@ -4338,6 +4339,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",

View file

@ -15,6 +15,7 @@
"@vueuse/head": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"fuse.js": "^7.0.0",
"lucide-vue-next": "^0.474.0",
"nostr-tools": "^2.10.4",

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { NostrClient, 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'
import { formatDistanceToNow } from 'date-fns'
const props = defineProps<{
relays?: string[]
}>()
const notes = ref<NostrNote[]>([])
const isLoading = ref(true)
const error = ref<Error | null>(null)
const relayUrls = props.relays || JSON.parse(import.meta.env.VITE_NOSTR_RELAYS as string)
const { isConnected, connect, disconnect } = useNostr({ relays: relayUrls })
async function loadNotes() {
try {
isLoading.value = true
error.value = null
const client = new NostrClient({ relays: relayUrls })
await client.connect()
notes.value = await client.fetchNotes({ limit: 50 })
} catch (err) {
error.value = err instanceof Error ? err : new Error('Failed to load notes')
} finally {
isLoading.value = false
}
}
onMounted(async () => {
await loadNotes()
})
onUnmounted(() => {
disconnect()
})
function formatDate(timestamp: number): string {
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
}
</script>
<template>
<div class="w-full max-w-3xl mx-auto p-4">
<Card class="w-full">
<CardHeader>
<CardTitle>Nostr Feed</CardTitle>
<CardDescription>Latest notes from the nostr network</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea class="h-[600px] w-full pr-4">
<div v-if="isLoading" 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>
<div v-else-if="notes.length === 0" class="text-center py-8 text-muted-foreground">
No notes found
</div>
<div v-else class="space-y-4">
<Card v-for="note in notes" :key="note.id" class="p-4">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="font-medium text-sm">{{ note.pubkey.slice(0, 8) }}...</span>
<span class="text-xs text-muted-foreground">{{ formatDate(note.created_at) }}</span>
</div>
<p class="text-sm">{{ note.content }}</p>
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<span>{{ note.replyCount }} replies</span>
<span>{{ note.reactionCount }} reactions</span>
</div>
</div>
</Card>
</div>
</ScrollArea>
</CardContent>
<CardFooter class="flex justify-between">
<button
class="text-sm text-primary hover:underline"
:disabled="isLoading"
@click="loadNotes"
>
Refresh
</button>
</CardFooter>
</Card>
</div>
</template>

View file

@ -1,9 +1,15 @@
import { SimplePool, getPublicKey, nip19 } from 'nostr-tools'
import { SimplePool, type Filter, type Event } from 'nostr-tools'
export interface NostrClientConfig {
relays: string[]
}
export interface NostrNote extends Event {
// Add any additional note-specific fields we want to track
replyCount?: number
reactionCount?: number
}
export class NostrClient {
private pool: SimplePool
private relays: string[]
@ -41,4 +47,80 @@ export class NostrClient {
this.pool.close(this.relays)
this._isConnected = false
}
async fetchNotes(options: {
limit?: number
since?: number // Unix timestamp in seconds
} = {}): Promise<NostrNote[]> {
const { limit = 20, since = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000) } = options
const filter: Filter = {
kinds: [1], // Regular notes
since,
limit
}
try {
// Get events from all relays
const events = await Promise.all(
this.relays.map(async (relay) => {
try {
return await this.pool.querySync([relay], filter)
} catch (error) {
console.warn(`Failed to fetch from relay ${relay}:`, error)
return []
}
})
)
// Flatten and deduplicate events by ID
const uniqueEvents = Array.from(
new Map(
events.flat().map(event => [event.id, event])
).values()
)
return uniqueEvents
.sort((a: Event, b: Event) => b.created_at - a.created_at) // Sort by newest first
.map((event: Event): NostrNote => ({
...event,
replyCount: 0, // We'll implement this later
reactionCount: 0 // We'll implement this later
}))
} catch (error) {
console.error('Failed to fetch notes:', error)
throw error
}
}
// Subscribe to new notes in real-time
subscribeToNotes(onNote: (note: NostrNote) => void): () => void {
const filters = [{
kinds: [1],
since: Math.floor(Date.now() / 1000)
}]
// Subscribe to each relay individually
const unsubscribes = this.relays.map(relay => {
const sub = this.pool.subscribeMany(
[relay],
filters,
{
onevent: (event: Event) => {
onNote({
...event,
replyCount: 0,
reactionCount: 0
})
}
}
)
return () => sub.close()
})
// Return a function that unsubscribes from all relays
return () => {
unsubscribes.forEach(unsub => unsub())
}
}
}

View file

@ -1,10 +1,12 @@
<template>
<div class="container py-8">
<NostrFeed />
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import NostrFeed from '@/components/nostr/NostrFeed.vue'
const { t } = useI18n()
</script>