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:
Dorian
2026-03-04 00:10:53 +00:00
co-authored by Claude Opus 4.6
parent 869db87fd5
commit 9b4980b669
2 changed files with 172 additions and 79 deletions
+120 -79
View File
@@ -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>
+52
View File
@@ -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,
}
}