feat(nostr): publish notes with per-relay status (M11.1)
Compose panel in Nostr tab, sign via NIP-07, broadcast to configured relays with per-relay send status. Character counter (soft 280 limit). Removed isDark conditionals from NostrGrid (dark-only app). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
869db87fd5
commit
9b4980b669
@@ -1,29 +1,77 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="p-4 space-y-3" :style="isDark
|
||||
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
|
||||
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
|
||||
>
|
||||
<div class="p-4 space-y-3 border-b border-white/[0.08]">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
<h3 class="text-sm font-bold text-white/90">
|
||||
Nostr Feed
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
<span class="text-[10px] font-mono text-white/30">
|
||||
{{ filteredNotes.length }} notes
|
||||
</span>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compose toggle -->
|
||||
<button
|
||||
v-if="isLoggedIn"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/40 bg-white/5 hover:bg-white/10 transition-colors"
|
||||
@click="showCompose = !showCompose"
|
||||
>
|
||||
{{ showCompose ? 'Cancel' : 'Write a note...' }}
|
||||
</button>
|
||||
|
||||
<!-- Compose panel -->
|
||||
<div v-if="showCompose && isLoggedIn" class="rounded-lg bg-white/5 border border-white/10 p-3 space-y-2">
|
||||
<textarea
|
||||
ref="composeRef"
|
||||
v-model="composeText"
|
||||
class="w-full bg-transparent text-xs text-white/80 placeholder:text-white/25 outline-none resize-none min-h-[80px]"
|
||||
placeholder="What's on your mind?"
|
||||
@keydown.meta.enter="publishNote"
|
||||
@keydown.ctrl.enter="publishNote"
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span
|
||||
class="text-[10px] tabular-nums"
|
||||
:class="composeText.length > 280 ? 'text-accent/80' : 'text-white/25'"
|
||||
>
|
||||
{{ composeText.length }}
|
||||
</span>
|
||||
<button
|
||||
class="text-[10px] px-3 py-1.5 rounded-lg bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
|
||||
:disabled="!composeText.trim() || isPublishing"
|
||||
@click="publishNote"
|
||||
>
|
||||
{{ isPublishing ? 'Publishing...' : 'Publish' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Publish results -->
|
||||
<div v-if="publishResults.length > 0" class="space-y-1">
|
||||
<div
|
||||
v-for="result in publishResults"
|
||||
:key="result.url"
|
||||
class="flex items-center gap-2 text-[10px] px-2 py-1 rounded bg-white/[0.02]"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="result.success ? 'bg-emerald-500' : 'bg-red-400/60'"
|
||||
/>
|
||||
<span class="truncate font-mono text-white/40">{{ result.url }}</span>
|
||||
<span class="ml-auto shrink-0" :class="result.success ? 'text-emerald-400/60' : 'text-red-400/60'">
|
||||
{{ result.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search notes, npubs..."
|
||||
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors"
|
||||
:class="isDark
|
||||
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
|
||||
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10"
|
||||
/>
|
||||
|
||||
<div class="flex gap-1.5">
|
||||
@@ -33,9 +81,7 @@
|
||||
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
|
||||
:class="activeKind === kind.id
|
||||
? 'nav-tab-active'
|
||||
: isDark
|
||||
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
|
||||
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
|
||||
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
|
||||
@click="activeKind = activeKind === kind.id ? null : kind.id"
|
||||
>
|
||||
{{ kind.label }}
|
||||
@@ -49,69 +95,42 @@
|
||||
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"
|
||||
>
|
||||
<svg class="w-5 h-5 animate-spin text-white/30" 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>
|
||||
<p class="text-xs text-white/30">Connecting to relays...</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="note in filteredNotes"
|
||||
:key="note.id"
|
||||
class="w-full text-left p-3 rounded-xl transition-all duration-150"
|
||||
:class="isDark
|
||||
? 'bg-white/[0.03] hover:bg-white/[0.07] border border-white/5'
|
||||
: 'bg-black/[0.02] hover:bg-black/[0.05] border border-black/5'"
|
||||
class="w-full text-left p-3 rounded-xl transition-all duration-150 bg-white/[0.03] hover:bg-white/[0.07] border border-white/5"
|
||||
@click="$emit('selectNote', note)"
|
||||
>
|
||||
<div class="flex items-start gap-2.5">
|
||||
<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 bg-purple-500/20 text-purple-400">
|
||||
{{ 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 text-white/80">
|
||||
{{ 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 text-purple-400/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 text-white/20">
|
||||
{{ 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 text-white/60">
|
||||
{{ 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'"
|
||||
class="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/30"
|
||||
>
|
||||
kind:{{ note.kind }}
|
||||
</span>
|
||||
@@ -121,35 +140,19 @@
|
||||
</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)'"
|
||||
>
|
||||
<p
|
||||
class="text-[10px] font-medium mb-2"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'"
|
||||
>
|
||||
Relays
|
||||
</p>
|
||||
<div class="mt-4 pt-4 border-t border-white/5">
|
||||
<p class="text-[10px] font-medium mb-2 text-white/30">Relays</p>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
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]'"
|
||||
class="flex items-center gap-2 text-[10px] px-2 py-1 rounded-lg bg-white/[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'"
|
||||
>
|
||||
{{ relay.url }}
|
||||
</span>
|
||||
<span class="truncate font-mono text-white/40">{{ relay.url }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,26 +161,29 @@
|
||||
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>
|
||||
<p class="text-sm text-white/30">No notes match your search</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useNostr, type NostrNote } from '@/composables/useNostr'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useNostr, type NostrNote, type PublishResult } from '@/composables/useNostr'
|
||||
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||
|
||||
defineEmits<{ selectNote: [note: NostrNote] }>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { events: notes, isConnected, relayStates, connect } = useNostr()
|
||||
const { events: notes, isConnected, relayStates, connect, publishEvent } = useNostr()
|
||||
const { isLoggedIn, signEvent } = useNostrIdentity()
|
||||
|
||||
const search = ref('')
|
||||
const activeKind = ref<number | null>(null)
|
||||
const showCompose = ref(false)
|
||||
const composeText = ref('')
|
||||
const composeRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const isPublishing = ref(false)
|
||||
const publishResults = ref<PublishResult[]>([])
|
||||
|
||||
const noteKinds = [
|
||||
{ id: 1, label: 'Notes' },
|
||||
@@ -210,8 +216,43 @@ const filteredNotes = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
// Connect to relays on mount (lazy-loaded on tab activation)
|
||||
onMounted(() => {
|
||||
async function publishNote() {
|
||||
if (!composeText.value.trim() || isPublishing.value) return
|
||||
|
||||
isPublishing.value = true
|
||||
publishResults.value = []
|
||||
|
||||
const unsigned = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: composeText.value.trim(),
|
||||
}
|
||||
|
||||
const signed = await signEvent(unsigned)
|
||||
if (!signed) {
|
||||
isPublishing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const results = await publishEvent(signed)
|
||||
publishResults.value = results
|
||||
isPublishing.value = false
|
||||
|
||||
if (results.some(r => r.success)) {
|
||||
composeText.value = ''
|
||||
setTimeout(() => {
|
||||
publishResults.value = []
|
||||
showCompose.value = false
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
connect()
|
||||
if (showCompose.value) {
|
||||
await nextTick()
|
||||
composeRef.value?.focus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -175,6 +175,57 @@ function disconnect() {
|
||||
initialized = false
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
url: string
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
function publishEvent(signedEvent: NostrEvent): Promise<PublishResult[]> {
|
||||
const connectedRelays = relays.filter(r => r.connected && r.ws)
|
||||
if (connectedRelays.length === 0) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
const results = connectedRelays.map(relay => {
|
||||
return new Promise<PublishResult>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve({ url: relay.url, success: false, message: 'Timeout' })
|
||||
}, 5000)
|
||||
|
||||
const handler = (msg: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data)
|
||||
// NIP-01: ["OK", <event_id>, <accepted>, <message>]
|
||||
if (Array.isArray(data) && data[0] === 'OK' && data[1] === signedEvent.id) {
|
||||
clearTimeout(timer)
|
||||
relay.ws?.removeEventListener('message', handler)
|
||||
resolve({
|
||||
url: relay.url,
|
||||
success: !!data[2],
|
||||
message: data[3] ?? (data[2] ? 'Published' : 'Rejected'),
|
||||
})
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
relay.ws!.addEventListener('message', handler)
|
||||
try {
|
||||
relay.ws!.send(JSON.stringify(['EVENT', signedEvent]))
|
||||
} catch {
|
||||
clearTimeout(timer)
|
||||
relay.ws?.removeEventListener('message', handler)
|
||||
resolve({ url: relay.url, success: false, message: 'Send failed' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Also add to our local feed
|
||||
addEvent(signedEvent)
|
||||
|
||||
return Promise.all(results)
|
||||
}
|
||||
|
||||
function fetchNote(hexId: string, timeoutMs = 5000): Promise<NostrNote | null> {
|
||||
// Check if already cached
|
||||
const cached = events.value.find(e => e.id === hexId)
|
||||
@@ -237,5 +288,6 @@ export function useNostr() {
|
||||
connect,
|
||||
disconnect,
|
||||
fetchNote,
|
||||
publishEvent,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user