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>
+191
View File
@@ -0,0 +1,191 @@
import { ref, shallowRef, onUnmounted } from 'vue'
export interface NostrEvent {
id: string
pubkey: string
kind: number
content: string
created_at: number
tags: string[][]
sig: string
}
export interface NostrNote {
id: string
pubkey: string
authorName?: string
nip05?: string
kind: number
content: string
created_at: number
tags: string[][]
}
interface RelayState {
url: string
ws: WebSocket | null
connected: boolean
}
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.snort.social',
]
const events = shallowRef<NostrNote[]>([])
const isConnected = ref(false)
const relayStates = ref<{ url: string; connected: boolean }[]>(
RELAYS.map(url => ({ url, connected: false }))
)
let relays: RelayState[] = []
let subscriptionId: string | null = null
let initialized = false
function generateSubId(): string {
return 'aiui-' + Math.random().toString(36).slice(2, 10)
}
function truncatePubkey(pubkey: string): string {
if (pubkey.length <= 12) return pubkey
return pubkey.slice(0, 8) + '...' + pubkey.slice(-4)
}
function parseEvent(data: unknown): NostrEvent | null {
if (!Array.isArray(data)) return null
// NIP-01: ["EVENT", <subscription_id>, <event>]
if (data[0] !== 'EVENT' || !data[2]) return null
const evt = data[2]
if (!evt.id || !evt.pubkey || typeof evt.kind !== 'number' || typeof evt.content !== 'string') {
return null
}
return evt as NostrEvent
}
function addEvent(evt: NostrEvent) {
// Deduplicate by ID
const existing = events.value.find(e => e.id === evt.id)
if (existing) return
const note: NostrNote = {
id: evt.id,
pubkey: evt.pubkey,
authorName: truncatePubkey(evt.pubkey),
kind: evt.kind,
content: evt.content,
created_at: evt.created_at,
tags: evt.tags ?? [],
}
// Insert sorted by created_at (newest first)
const newEvents = [...events.value, note]
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 200) // Cap at 200 events
events.value = newEvents
}
function connectRelay(relayState: RelayState) {
if (relayState.ws) return
try {
const ws = new WebSocket(relayState.url)
relayState.ws = ws
ws.onopen = () => {
relayState.connected = true
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
// Subscribe to kind:1 (text notes) with limit 50
if (!subscriptionId) subscriptionId = generateSubId()
const req = JSON.stringify([
'REQ',
subscriptionId,
{ kinds: [1], limit: 50 },
])
ws.send(req)
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
const evt = parseEvent(data)
if (evt) addEvent(evt)
} catch {
// Skip malformed messages
}
}
ws.onclose = () => {
relayState.connected = false
relayState.ws = null
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
}
ws.onerror = () => {
// Will trigger onclose
}
} catch {
relayState.connected = false
updateRelayStates()
}
}
function updateRelayStates() {
relayStates.value = relays.map(r => ({
url: r.url,
connected: r.connected,
}))
}
function connect() {
if (initialized) return
initialized = true
relays = RELAYS.map(url => ({ url, ws: null, connected: false }))
updateRelayStates()
relays.forEach(r => connectRelay(r))
}
function disconnect() {
if (subscriptionId) {
relays.forEach(r => {
if (r.ws && r.connected) {
try {
r.ws.send(JSON.stringify(['CLOSE', subscriptionId]))
} catch { /* ignore */ }
}
})
subscriptionId = null
}
relays.forEach(r => {
if (r.ws) {
r.ws.close()
r.ws = null
r.connected = false
}
})
updateRelayStates()
isConnected.value = false
initialized = false
}
export function useNostr() {
onUnmounted(() => {
// Clean up on component unmount
disconnect()
})
return {
events,
isConnected,
relayStates,
connect,
disconnect,
}
}