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",
|
"@vueuse/head": "^2.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"lucide-vue-next": "^0.474.0",
|
"lucide-vue-next": "^0.474.0",
|
||||||
"nostr-tools": "^2.10.4",
|
"nostr-tools": "^2.10.4",
|
||||||
|
|
@ -4338,6 +4339,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/de-indent": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"@vueuse/head": "^2.0.0",
|
"@vueuse/head": "^2.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"lucide-vue-next": "^0.474.0",
|
"lucide-vue-next": "^0.474.0",
|
||||||
"nostr-tools": "^2.10.4",
|
"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 {
|
export interface NostrClientConfig {
|
||||||
relays: string[]
|
relays: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NostrNote extends Event {
|
||||||
|
// Add any additional note-specific fields we want to track
|
||||||
|
replyCount?: number
|
||||||
|
reactionCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
export class NostrClient {
|
export class NostrClient {
|
||||||
private pool: SimplePool
|
private pool: SimplePool
|
||||||
private relays: string[]
|
private relays: string[]
|
||||||
|
|
@ -41,4 +47,80 @@ export class NostrClient {
|
||||||
this.pool.close(this.relays)
|
this.pool.close(this.relays)
|
||||||
this._isConnected = false
|
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>
|
<template>
|
||||||
|
<div class="container py-8">
|
||||||
|
<NostrFeed />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import NostrFeed from '@/components/nostr/NostrFeed.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue