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:
co-authored by
Claude Opus 4.6
parent
8f2f927ff1
commit
f4e8614730
@@ -24,6 +24,9 @@
|
|||||||
<!-- Profile sub-tab -->
|
<!-- Profile sub-tab -->
|
||||||
<NostrProfileEditor v-else-if="activeSubTab === 'profile'" />
|
<NostrProfileEditor v-else-if="activeSubTab === 'profile'" />
|
||||||
|
|
||||||
|
<!-- Lists sub-tab -->
|
||||||
|
<NostrLists v-else-if="activeSubTab === 'lists'" />
|
||||||
|
|
||||||
<!-- Thread view -->
|
<!-- Thread view -->
|
||||||
<NostrThread
|
<NostrThread
|
||||||
v-else-if="activeSubTab === 'feed' && selectedNoteId"
|
v-else-if="activeSubTab === 'feed' && selectedNoteId"
|
||||||
@@ -242,6 +245,7 @@ import NostrRelayManager from './NostrRelayManager.vue'
|
|||||||
import NostrProfileEditor from './NostrProfileEditor.vue'
|
import NostrProfileEditor from './NostrProfileEditor.vue'
|
||||||
import ZapDialog from './ZapDialog.vue'
|
import ZapDialog from './ZapDialog.vue'
|
||||||
import NostrThread from './NostrThread.vue'
|
import NostrThread from './NostrThread.vue'
|
||||||
|
import NostrLists from './NostrLists.vue'
|
||||||
import { useNip05Verification } from '@/composables/useNip05Verification'
|
import { useNip05Verification } from '@/composables/useNip05Verification'
|
||||||
import { useNostr, type NostrNote, type PublishResult } from '@/composables/useNostr'
|
import { useNostr, type NostrNote, type PublishResult } from '@/composables/useNostr'
|
||||||
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||||
@@ -251,10 +255,11 @@ const { events: notes, isConnected, relayStates, connect, publishEvent, searchRe
|
|||||||
const { isLoggedIn, signEvent } = useNostrIdentity()
|
const { isLoggedIn, signEvent } = useNostrIdentity()
|
||||||
const { verifyNip05 } = useNip05Verification()
|
const { verifyNip05 } = useNip05Verification()
|
||||||
|
|
||||||
const activeSubTab = ref<'feed' | 'dms' | 'relays' | 'profile'>('feed')
|
const activeSubTab = ref<'feed' | 'dms' | 'relays' | 'profile' | 'lists'>('feed')
|
||||||
const subTabs = [
|
const subTabs = [
|
||||||
{ id: 'feed' as const, label: 'Feed' },
|
{ id: 'feed' as const, label: 'Feed' },
|
||||||
{ id: 'dms' as const, label: 'Messages' },
|
{ id: 'dms' as const, label: 'Messages' },
|
||||||
|
{ id: 'lists' as const, label: 'Lists' },
|
||||||
{ id: 'relays' as const, label: 'Relays' },
|
{ id: 'relays' as const, label: 'Relays' },
|
||||||
{ id: 'profile' as const, label: 'Profile' },
|
{ 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>
|
||||||
Reference in New Issue
Block a user