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:
parent
68d6001880
commit
00f4bfa583
5 changed files with 195 additions and 3 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
96
src/components/nostr/NostrFeed.vue
Normal file
96
src/components/nostr/NostrFeed.vue
Normal 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>
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue