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:
co-authored by
Claude Opus 4.6
parent
31f2c2b261
commit
ebaee4c2b9
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user