feat(nostr): thread view with nested replies up to 5 levels (M11.8)

Clicking a note opens thread view fetching root + replies via #e tag.
Renders as threaded tree with indentation (max 5 levels). Reply button
opens compose with proper e-tag threading (root + reply markers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 00:27:46 +00:00
co-authored by Claude Opus 4.6
parent ffc6524286
commit 8f2f927ff1
3 changed files with 323 additions and 2 deletions
@@ -24,6 +24,13 @@
<!-- Profile sub-tab -->
<NostrProfileEditor v-else-if="activeSubTab === 'profile'" />
<!-- Thread view -->
<NostrThread
v-else-if="activeSubTab === 'feed' && selectedNoteId"
:note-id="selectedNoteId"
@back="selectedNoteId = null"
/>
<!-- Feed sub-tab -->
<template v-else>
<div class="p-4 space-y-3 border-b border-white/[0.08]">
@@ -143,7 +150,7 @@
v-for="note in filteredNotes"
:key="note.id"
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)"
@click="selectedNoteId = note.id"
>
<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 bg-purple-500/20 text-purple-400">
@@ -234,11 +241,11 @@ import NostrDMs from './NostrDMs.vue'
import NostrRelayManager from './NostrRelayManager.vue'
import NostrProfileEditor from './NostrProfileEditor.vue'
import ZapDialog from './ZapDialog.vue'
import NostrThread from './NostrThread.vue'
import { useNip05Verification } from '@/composables/useNip05Verification'
import { useNostr, type NostrNote, type PublishResult } from '@/composables/useNostr'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
defineEmits<{ selectNote: [note: NostrNote] }>()
const { events: notes, isConnected, relayStates, connect, publishEvent, searchResults, isSearching, searchNostr } = useNostr()
const { isLoggedIn, signEvent } = useNostrIdentity()
@@ -252,6 +259,7 @@ const subTabs = [
{ id: 'profile' as const, label: 'Profile' },
]
const selectedNoteId = ref<string | null>(null)
const search = ref('')
const activeKind = ref<number | null>(null)
const showCompose = ref(false)
@@ -0,0 +1,263 @@
<template>
<div class="h-full flex flex-col">
<div class="flex items-center gap-2 px-4 py-3 border-b border-white/[0.08]">
<button
class="w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
@click="$emit('back')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h3 class="text-sm font-bold text-white/90">Thread</h3>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">Loading thread...</p>
</div>
<!-- Root note -->
<div v-if="rootNote" class="rounded-xl bg-white/[0.05] border border-white/10 p-3">
<div class="flex items-center gap-1.5 mb-1">
<div class="w-6 h-6 rounded-full shrink-0 flex items-center justify-center text-[9px] font-bold bg-purple-500/20 text-purple-400">
{{ rootNote.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
<span class="text-xs font-semibold text-white/80">{{ rootNote.authorName ?? 'anon' }}</span>
<span class="text-[9px] ml-auto text-white/20">{{ formatTime(rootNote.created_at) }}</span>
</div>
<p class="text-xs text-white/70 leading-relaxed whitespace-pre-wrap">{{ rootNote.content }}</p>
</div>
<!-- Replies -->
<div v-if="threadTree.length > 0" class="space-y-1">
<p class="text-[10px] text-white/30 font-medium mt-3 mb-1">{{ threadTree.length }} replies</p>
<ThreadNode
v-for="node in threadTree"
:key="node.note.id"
:node="node"
:depth="0"
@reply="startReply"
/>
</div>
<div v-if="!isLoading && !rootNote" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">Thread not found</p>
</div>
</div>
<!-- Reply input -->
<div v-if="rootNote && isLoggedIn" class="px-4 py-3 border-t border-white/[0.08]">
<p v-if="replyTo" class="text-[9px] text-white/30 mb-1">
Replying to {{ replyTo.authorName ?? 'anon' }}
<button class="text-accent/60 ml-1" @click="replyTo = null">cancel</button>
</p>
<div class="flex gap-2">
<input
v-model="replyText"
type="text"
placeholder="Reply..."
class="flex-1 px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
@keydown.enter="sendReply"
/>
<button
class="px-3 py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!replyText.trim()"
@click="sendReply"
>
Reply
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, defineAsyncComponent } from 'vue'
import { useNostr, type NostrNote, type NostrEvent } from '@/composables/useNostr'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
const ThreadNode = defineAsyncComponent(() => import('./ThreadNode.vue'))
const props = defineProps<{
noteId: string
}>()
defineEmits<{ back: [] }>()
const { fetchNote, publishEvent } = useNostr()
const { isLoggedIn, signEvent, pubkey } = useNostrIdentity()
interface ThreadTreeNode {
note: NostrNote
children: ThreadTreeNode[]
}
const rootNote = ref<NostrNote | null>(null)
const threadTree = ref<ThreadTreeNode[]>([])
const isLoading = ref(true)
const replyTo = ref<NostrNote | null>(null)
const replyText = ref('')
function formatTime(ts: number): string {
const d = new Date(ts * 1000)
return d.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' })
+ ' ' + d.toLocaleDateString('en', { month: 'short', day: 'numeric' })
}
function truncatePubkey(pk: string): string {
if (pk.length <= 12) return pk
return pk.slice(0, 8) + '...' + pk.slice(-4)
}
async function loadThread() {
isLoading.value = true
// Fetch root note
const root = await fetchNote(props.noteId)
if (!root) {
isLoading.value = false
return
}
rootNote.value = root
// Fetch replies (kind:1 with #e tag referencing this note)
const replies = await fetchReplies(props.noteId)
threadTree.value = buildTree(replies, props.noteId)
isLoading.value = false
}
async function fetchReplies(rootId: string): Promise<NostrNote[]> {
return new Promise((resolve) => {
const results: NostrNote[] = []
const subId = 'thread-' + Math.random().toString(36).slice(2, 8)
let resolved = false
const timer = setTimeout(() => {
if (!resolved) { resolved = true; resolve(results) }
}, 8000)
// Connect to first available NIP-50 relay for broader search
const relayUrl = 'wss://relay.nostr.band'
try {
const ws = new WebSocket(relayUrl)
ws.onopen = () => {
ws.send(JSON.stringify([
'REQ', subId,
{ kinds: [1], '#e': [rootId], limit: 100 },
]))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (Array.isArray(data) && data[0] === 'EVENT' && data[1] === subId && data[2]) {
const evt = data[2] as NostrEvent
if (!results.find(r => r.id === evt.id)) {
results.push({
id: evt.id,
pubkey: evt.pubkey,
authorName: truncatePubkey(evt.pubkey),
kind: evt.kind,
content: evt.content,
created_at: evt.created_at,
tags: evt.tags ?? [],
})
}
}
if (Array.isArray(data) && data[0] === 'EOSE' && data[1] === subId) {
clearTimeout(timer)
ws.close()
if (!resolved) { resolved = true; resolve(results) }
}
} catch { /* skip */ }
}
ws.onerror = () => {
clearTimeout(timer)
if (!resolved) { resolved = true; resolve(results) }
}
ws.onclose = () => {
if (!resolved) { resolved = true; resolve(results) }
}
} catch {
clearTimeout(timer)
resolve(results)
}
})
}
function buildTree(replies: NostrNote[], rootId: string, maxDepth = 5): ThreadTreeNode[] {
const childrenMap = new Map<string, NostrNote[]>()
for (const reply of replies) {
// Find the parent — last 'e' tag with 'reply' marker, or last 'e' tag
let parentId = rootId
const eTags = reply.tags.filter(t => t[0] === 'e')
if (eTags.length > 0) {
const replyTag = eTags.find(t => t[3] === 'reply')
parentId = replyTag ? replyTag[1] : eTags[eTags.length - 1][1]
}
const siblings = childrenMap.get(parentId) ?? []
siblings.push(reply)
childrenMap.set(parentId, siblings)
}
function build(parentId: string, depth: number): ThreadTreeNode[] {
const children = childrenMap.get(parentId) ?? []
children.sort((a, b) => a.created_at - b.created_at)
return children.map(note => ({
note,
children: depth < maxDepth ? build(note.id, depth + 1) : [],
}))
}
return build(rootId, 0)
}
function startReply(note: NostrNote) {
replyTo.value = note
}
async function sendReply() {
if (!replyText.value.trim() || !pubkey.value) return
const target = replyTo.value ?? rootNote.value
if (!target) return
const tags: string[][] = [
['e', props.noteId, '', 'root'],
]
if (target.id !== props.noteId) {
tags.push(['e', target.id, '', 'reply'])
}
tags.push(['p', target.pubkey])
const unsigned = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content: replyText.value.trim(),
}
const signed = await signEvent(unsigned)
if (!signed) return
await publishEvent(signed)
replyText.value = ''
replyTo.value = null
// Reload thread
await loadThread()
}
onMounted(() => {
loadThread()
})
</script>
@@ -0,0 +1,50 @@
<template>
<div :style="{ paddingLeft: `${Math.min(depth, 4) * 16}px` }">
<div class="rounded-lg bg-white/[0.03] border border-white/5 p-2.5 mb-1">
<div class="flex items-center gap-1.5 mb-1">
<div class="w-5 h-5 rounded-full shrink-0 flex items-center justify-center text-[8px] font-bold bg-purple-500/20 text-purple-400">
{{ node.note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
<span class="text-[10px] font-semibold text-white/70">{{ node.note.authorName ?? 'anon' }}</span>
<span class="text-[8px] ml-auto text-white/20">{{ formatTime(node.note.created_at) }}</span>
</div>
<p class="text-[11px] text-white/60 leading-relaxed whitespace-pre-wrap">{{ node.note.content }}</p>
<button
class="text-[8px] text-white/25 hover:text-accent/60 mt-1 transition-colors"
@click="$emit('reply', node.note)"
>
Reply
</button>
</div>
<!-- Children (recursive) -->
<ThreadNode
v-for="child in node.children"
:key="child.note.id"
:node="child"
:depth="depth + 1"
@reply="(note: NostrNote) => $emit('reply', note)"
/>
</div>
</template>
<script setup lang="ts">
import type { NostrNote } from '@/composables/useNostr'
interface ThreadTreeNode {
note: NostrNote
children: ThreadTreeNode[]
}
defineProps<{
node: ThreadTreeNode
depth: number
}>()
defineEmits<{ reply: [note: NostrNote] }>()
function formatTime(ts: number): string {
const d = new Date(ts * 1000)
return d.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' })
}
</script>