feat(app): add Nostr feed integration with real relay connections

Create useNostr composable with raw WebSocket connections to public
relays (relay.damus.io, nos.lol, relay.snort.social). Subscribe to
kind:1 notes with limit 50. Update NostrGrid to use real data instead
of mock notes. Lazy-load on tab activation, clean disconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 20:32:15 +00:00
co-authored by Claude Opus 4.6
parent 31f2c2b261
commit ebaee4c2b9
2 changed files with 268 additions and 138 deletions
+77 -138
View File
@@ -44,6 +44,25 @@
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<!-- Loading state -->
<div
v-if="!isConnected && notes.length === 0"
class="flex flex-col items-center justify-center py-12 gap-3"
>
<svg
class="w-5 h-5 animate-spin"
:class="isDark ? 'text-white/30' : 'text-gray-400'"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Connecting to relays...
</p>
</div>
<button
v-for="note in filteredNotes"
:key="note.id"
@@ -54,74 +73,91 @@
@click="$emit('selectNote', note)"
>
<div class="flex items-start gap-2.5">
<!-- Avatar -->
<div class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-[10px] font-bold"
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'">
<div
class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-[10px] font-bold"
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'"
>
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/80' : 'text-gray-800'">
<span
class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
{{ note.authorName ?? 'anon' }}
</span>
<span v-if="note.nip05" class="text-[9px] truncate"
:class="isDark ? 'text-purple-400/60' : 'text-purple-500/60'">
<span
v-if="note.nip05"
class="text-[9px] truncate"
:class="isDark ? 'text-purple-400/60' : 'text-purple-500/60'"
>
{{ note.nip05 }}
</span>
<span class="text-[9px] ml-auto shrink-0"
:class="isDark ? 'text-white/20' : 'text-gray-300'">
<span
class="text-[9px] ml-auto shrink-0"
:class="isDark ? 'text-white/20' : 'text-gray-300'"
>
{{ formatTime(note.created_at) }}
</span>
</div>
<p class="text-[11px] mt-1 leading-relaxed line-clamp-3"
:class="isDark ? 'text-white/60' : 'text-gray-600'">
<p
class="text-[11px] mt-1 leading-relaxed line-clamp-3"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
>
{{ note.content }}
</p>
<div class="flex items-center gap-3 mt-2">
<span v-if="note.kind !== 1" class="text-[9px] px-1.5 py-0.5 rounded"
:class="isDark ? 'bg-white/5 text-white/30' : 'bg-black/5 text-gray-400'">
<span
v-if="note.kind !== 1"
class="text-[9px] px-1.5 py-0.5 rounded"
:class="isDark ? 'bg-white/5 text-white/30' : 'bg-black/5 text-gray-400'"
>
kind:{{ note.kind }}
</span>
<span v-if="note.replies" class="text-[9px]"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
{{ note.replies }} replies
</span>
<span v-if="note.zaps" class="text-[9px] text-amber-500/70">
{{ note.zaps }} sats
</span>
</div>
</div>
</div>
</button>
<!-- Relay status -->
<div class="mt-4 pt-4" :style="isDark
? 'border-top: 1px solid rgba(255, 255, 255, 0.05)'
: 'border-top: 1px solid rgba(0, 0, 0, 0.05)'"
<div
class="mt-4 pt-4"
:style="isDark
? 'border-top: 1px solid rgba(255, 255, 255, 0.05)'
: 'border-top: 1px solid rgba(0, 0, 0, 0.05)'"
>
<p class="text-[10px] font-medium mb-2"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
<p
class="text-[10px] font-medium mb-2"
:class="isDark ? 'text-white/30' : 'text-gray-400'"
>
Relays
</p>
<div class="space-y-1">
<div
v-for="relay in relays"
v-for="relay in relayStates"
:key="relay.url"
class="flex items-center gap-2 text-[10px] px-2 py-1 rounded-lg"
:class="isDark ? 'bg-white/[0.02]' : 'bg-black/[0.02]'"
>
<span class="w-1.5 h-1.5 rounded-full shrink-0"
:class="relay.connected ? 'bg-emerald-500' : 'bg-red-400/60'" />
<span class="truncate font-mono"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
<span
class="w-1.5 h-1.5 rounded-full shrink-0"
:class="relay.connected ? 'bg-emerald-500' : 'bg-red-400/60'"
/>
<span
class="truncate font-mono"
:class="isDark ? 'text-white/40' : 'text-gray-500'"
>
{{ relay.url }}
</span>
</div>
</div>
</div>
<div v-if="filteredNotes.length === 0" class="flex items-center justify-center py-12">
<div
v-if="isConnected && filteredNotes.length === 0"
class="flex items-center justify-center py-12"
>
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No notes match your search
</p>
@@ -131,30 +167,15 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme'
export interface NostrNote {
id: string
pubkey: string
authorName?: string
nip05?: string
kind: number
content: string
created_at: number
replies?: number
zaps?: number
tags?: string[][]
}
interface Relay {
url: string
connected: boolean
}
import { useNostr, type NostrNote } from '@/composables/useNostr'
defineEmits<{ selectNote: [note: NostrNote] }>()
const { isDark } = useTheme()
const { events: notes, isConnected, relayStates, connect } = useNostr()
const search = ref('')
const activeKind = ref<number | null>(null)
@@ -165,93 +186,6 @@ const noteKinds = [
{ id: 6, label: 'Reposts' },
]
const relays: Relay[] = [
{ url: 'wss://relay.damus.io', connected: true },
{ url: 'wss://relay.nostr.band', connected: true },
{ url: 'wss://nos.lol', connected: true },
{ url: 'wss://relay.snort.social', connected: false },
]
// Mock notes for demo — will be replaced with real Nostr websocket data
const mockNotes: NostrNote[] = [
{
id: 'note1abc',
pubkey: 'npub1abc',
authorName: 'fiatjaf',
nip05: 'fiatjaf@fiatjaf.com',
kind: 1,
content: 'nostr is the simplest open protocol that is able to create a censorship-resistant global "social" network once and for all.',
created_at: Date.now() / 1000 - 3600,
replies: 42,
zaps: 21000,
},
{
id: 'note2def',
pubkey: 'npub2def',
authorName: 'jb55',
nip05: 'jb55@jb55.com',
kind: 1,
content: 'Just shipped a new update to Damus. Lots of performance improvements and better zap UX. Let me know what you think!',
created_at: Date.now() / 1000 - 7200,
replies: 18,
zaps: 5000,
},
{
id: 'note3ghi',
pubkey: 'npub3ghi',
authorName: 'ODELL',
nip05: 'odell@citadeldispatch.com',
kind: 1,
content: 'Bitcoin and nostr are complementary protocols. One provides sound money, the other provides sound communication. Together they form the foundation of a free and open internet.',
created_at: Date.now() / 1000 - 14400,
replies: 31,
zaps: 15000,
},
{
id: 'note4jkl',
pubkey: 'npub4jkl',
authorName: 'Gigi',
nip05: 'gigi@dergigi.com',
kind: 30023,
content: 'New long-form article: "Why Nostr Matters" — exploring the implications of a truly censorship-resistant communication layer and what it means for the future of online discourse.',
created_at: Date.now() / 1000 - 28800,
replies: 12,
zaps: 50000,
},
{
id: 'note5mno',
pubkey: 'npub5mno',
authorName: 'Karnage',
kind: 1,
content: 'Building on nostr feels like the early web. Permissionless innovation, interoperable clients, and a community that actually cares about freedom. This is what I signed up for.',
created_at: Date.now() / 1000 - 36000,
replies: 8,
zaps: 3000,
},
{
id: 'note6pqr',
pubkey: 'npub6pqr',
authorName: 'Pablo',
nip05: 'pablo@fountain.fm',
kind: 1,
content: 'Podcasting 2.0 + Nostr = the future of content discovery. Imagine your podcast app pulling recommendations from your nostr social graph. We are building this.',
created_at: Date.now() / 1000 - 43200,
replies: 25,
zaps: 8000,
},
{
id: 'note7stu',
pubkey: 'npub7stu',
authorName: 'Calle',
nip05: 'calle@cashu.space',
kind: 1,
content: 'Cashu + Nostr ecash tokens make micropayments frictionless. Zap someone a few sats with zero fees and instant settlement. The Lightning Network as a settlement layer.',
created_at: Date.now() / 1000 - 50400,
replies: 15,
zaps: 12000,
},
]
function formatTime(ts: number): string {
const diff = Math.floor(Date.now() / 1000 - ts)
if (diff < 60) return 'now'
@@ -261,7 +195,7 @@ function formatTime(ts: number): string {
}
const filteredNotes = computed(() => {
let result = mockNotes
let result = notes.value
if (activeKind.value !== null) {
result = result.filter(n => n.kind === activeKind.value)
}
@@ -275,4 +209,9 @@ const filteredNotes = computed(() => {
}
return result
})
// Connect to relays on mount (lazy-loaded on tab activation)
onMounted(() => {
connect()
})
</script>