feat(nostr): NIP-51 lists — follows, mute, pin, bookmarks (M11.9)

View and manage follow (kind:3), mute (kind:10000), pin (kind:10001),
and bookmark (kind:10003) lists. Add/remove items, publish via NIP-07.
Lists sub-tab in Nostr section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 00:29:26 +00:00
co-authored by Claude Opus 4.6
parent 8f2f927ff1
commit f4e8614730
2 changed files with 274 additions and 1 deletions
@@ -24,6 +24,9 @@
<!-- Profile sub-tab -->
<NostrProfileEditor v-else-if="activeSubTab === 'profile'" />
<!-- Lists sub-tab -->
<NostrLists v-else-if="activeSubTab === 'lists'" />
<!-- Thread view -->
<NostrThread
v-else-if="activeSubTab === 'feed' && selectedNoteId"
@@ -242,6 +245,7 @@ import NostrRelayManager from './NostrRelayManager.vue'
import NostrProfileEditor from './NostrProfileEditor.vue'
import ZapDialog from './ZapDialog.vue'
import NostrThread from './NostrThread.vue'
import NostrLists from './NostrLists.vue'
import { useNip05Verification } from '@/composables/useNip05Verification'
import { useNostr, type NostrNote, type PublishResult } from '@/composables/useNostr'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
@@ -251,10 +255,11 @@ const { events: notes, isConnected, relayStates, connect, publishEvent, searchRe
const { isLoggedIn, signEvent } = useNostrIdentity()
const { verifyNip05 } = useNip05Verification()
const activeSubTab = ref<'feed' | 'dms' | 'relays' | 'profile'>('feed')
const activeSubTab = ref<'feed' | 'dms' | 'relays' | 'profile' | 'lists'>('feed')
const subTabs = [
{ id: 'feed' as const, label: 'Feed' },
{ id: 'dms' as const, label: 'Messages' },
{ id: 'lists' as const, label: 'Lists' },
{ id: 'relays' as const, label: 'Relays' },
{ id: 'profile' as const, label: 'Profile' },
]
@@ -0,0 +1,268 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 border-b border-white/[0.08]">
<h3 class="text-sm font-bold text-white/90 mb-3">Nostr Lists</h3>
<!-- List type selector -->
<div class="flex gap-1.5 flex-wrap">
<button
v-for="lt in listTypes"
:key="lt.kind"
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
:class="activeListKind === lt.kind
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
@click="activeListKind = lt.kind; loadList(lt.kind)"
>
{{ lt.label }}
</button>
</div>
</div>
<div v-if="!isLoggedIn" class="flex-1 flex items-center justify-center">
<p class="text-xs text-white/30">Sign in with Nostr to manage lists</p>
</div>
<div v-else 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 list...</p>
</div>
<!-- Add item -->
<div class="flex gap-2 mb-3">
<input
v-model="newItemValue"
type="text"
:placeholder="activeListKind === 3 ? 'Add npub or hex pubkey...' : 'Add item (hex id or npub)...'"
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 font-mono"
@keydown.enter="addItem"
/>
<button
class="px-2.5 py-2 rounded-lg text-[10px] bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!newItemValue.trim()"
@click="addItem"
>
Add
</button>
</div>
<!-- List items -->
<div
v-for="item in listItems"
:key="item.value"
class="flex items-center gap-2 p-2.5 rounded-xl bg-white/[0.03] border border-white/5"
>
<div class="w-6 h-6 rounded-full shrink-0 flex items-center justify-center text-[8px] font-bold bg-purple-500/20 text-purple-400">
{{ item.tag === 'p' ? 'P' : item.tag === 'e' ? 'E' : item.tag === 't' ? '#' : '?' }}
</div>
<div class="flex-1 min-w-0">
<p class="text-[10px] text-white/60 font-mono truncate">{{ item.displayValue }}</p>
<p v-if="item.petname" class="text-[9px] text-white/30">{{ item.petname }}</p>
</div>
<button
class="text-[9px] px-2 py-1 rounded bg-white/5 text-red-400/50 hover:text-red-400/80 hover:bg-red-400/10 transition-colors shrink-0"
@click="removeItem(item)"
>
Remove
</button>
</div>
<div v-if="!isLoading && listItems.length === 0" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">List is empty</p>
</div>
<!-- Publish button -->
<button
v-if="isDirty"
class="w-full py-2.5 rounded-lg text-xs font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30 mt-4"
:disabled="isPublishingList"
@click="publishList"
>
{{ isPublishingList ? 'Publishing...' : 'Publish updated list' }}
</button>
<p v-if="publishStatus" class="text-[10px] text-center" :class="publishOk ? 'text-emerald-400/60' : 'text-red-400/60'">
{{ publishStatus }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
import { useNostr, type NostrEvent } from '@/composables/useNostr'
import { decodeNpub, encodeNpub } from '@/utils/bech32'
const { isLoggedIn, signEvent, pubkey } = useNostrIdentity()
const { publishEvent } = useNostr()
interface ListItem {
tag: string
value: string
displayValue: string
relay?: string
petname?: string
}
const listTypes = [
{ kind: 3, label: 'Follows' },
{ kind: 10000, label: 'Mute' },
{ kind: 10001, label: 'Pin' },
{ kind: 10003, label: 'Bookmarks' },
]
const activeListKind = ref(3)
const listItems = ref<ListItem[]>([])
const isLoading = ref(false)
const isDirty = ref(false)
const newItemValue = ref('')
const isPublishingList = ref(false)
const publishStatus = ref('')
const publishOk = ref(false)
function truncate(hex: string): string {
if (hex.length <= 16) return hex
return hex.slice(0, 8) + '...' + hex.slice(-8)
}
function tagsToItems(tags: string[][]): ListItem[] {
return tags
.filter(t => t[0] === 'p' || t[0] === 'e' || t[0] === 't')
.map(t => {
let displayValue = truncate(t[1])
if (t[0] === 'p') {
try { displayValue = encodeNpub(t[1]) } catch { /* keep hex */ }
}
return {
tag: t[0],
value: t[1],
displayValue,
relay: t[2] || undefined,
petname: t[3] || undefined,
}
})
}
async function loadList(kind: number) {
if (!pubkey.value) return
isLoading.value = true
isDirty.value = false
listItems.value = []
try {
const ws = new WebSocket('wss://relay.nostr.band')
const subId = 'list-' + Math.random().toString(36).slice(2, 8)
const timer = setTimeout(() => {
ws.close()
isLoading.value = false
}, 8000)
ws.onopen = () => {
ws.send(JSON.stringify([
'REQ', subId,
{ kinds: [kind], authors: [pubkey.value], limit: 1 },
]))
}
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
listItems.value = tagsToItems(evt.tags)
}
if (Array.isArray(data) && data[0] === 'EOSE') {
clearTimeout(timer)
ws.close()
isLoading.value = false
}
} catch { /* skip */ }
}
ws.onerror = () => {
clearTimeout(timer)
isLoading.value = false
}
} catch {
isLoading.value = false
}
}
function addItem() {
let val = newItemValue.value.trim()
if (!val) return
let hex = val
const tag = activeListKind.value === 3 || activeListKind.value === 10000 ? 'p' : 'e'
if (val.startsWith('npub')) {
try { hex = decodeNpub(val) } catch { return }
}
if (listItems.value.find(i => i.value === hex)) return
let displayValue = truncate(hex)
if (tag === 'p') {
try { displayValue = encodeNpub(hex) } catch { /* keep hex */ }
}
listItems.value.push({ tag, value: hex, displayValue })
isDirty.value = true
newItemValue.value = ''
}
function removeItem(item: ListItem) {
listItems.value = listItems.value.filter(i => i.value !== item.value)
isDirty.value = true
}
async function publishList() {
if (!pubkey.value) return
isPublishingList.value = true
publishStatus.value = ''
const tags = listItems.value.map(i => {
const t = [i.tag, i.value]
if (i.relay) t.push(i.relay)
if (i.petname) t.push(i.petname)
return t
})
const unsigned = {
kind: activeListKind.value,
created_at: Math.floor(Date.now() / 1000),
tags,
content: '',
}
const signed = await signEvent(unsigned)
if (!signed) {
isPublishingList.value = false
publishStatus.value = 'Signing failed'
publishOk.value = false
return
}
const results = await publishEvent(signed)
const successes = results.filter(r => r.success).length
isPublishingList.value = false
isDirty.value = false
if (successes > 0) {
publishStatus.value = `Published to ${successes}/${results.length} relays`
publishOk.value = true
} else {
publishStatus.value = 'Failed to publish'
publishOk.value = false
}
}
onMounted(() => {
loadList(activeListKind.value)
})
</script>