feat(nostr): encrypted DMs with inbox and thread view (M11.2)
NIP-04 encrypted DMs with IDB persistence. DM inbox tab in Nostr section with contact list, message threads, and new conversation initiation. Added decodeNpub utility. Sub-tab switcher (Feed/Messages). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9b4980b669
commit
3a0d63f2b1
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<!-- Thread view -->
|
||||||
|
<template v-if="activeThread">
|
||||||
|
<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="clearActiveContact"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs font-semibold text-white/80 truncate">{{ activeThread.contactName }}</p>
|
||||||
|
<p class="text-[9px] text-white/30 font-mono truncate">{{ activeThread.contactPubkey }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="messagesRef" class="flex-1 overflow-y-auto custom-scrollbar px-4 py-3 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="msg in activeThread.messages"
|
||||||
|
:key="msg.id"
|
||||||
|
class="flex"
|
||||||
|
:class="msg.fromPubkey === pubkey ? 'justify-end' : 'justify-start'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-w-[80%] rounded-xl px-3 py-2"
|
||||||
|
:class="msg.fromPubkey === pubkey
|
||||||
|
? 'bg-accent/15 text-white/80'
|
||||||
|
: 'bg-white/5 text-white/70'"
|
||||||
|
>
|
||||||
|
<p class="text-xs leading-relaxed break-words">{{ msg.content }}</p>
|
||||||
|
<p class="text-[9px] mt-1 text-white/25 tabular-nums">{{ formatTime(msg.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message input -->
|
||||||
|
<div class="px-4 py-3 border-t border-white/[0.08]">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="messageInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
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="sendMessage"
|
||||||
|
/>
|
||||||
|
<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="!messageInput.trim() || isSending"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Contact list / inbox -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="p-4 border-b border-white/[0.08]">
|
||||||
|
<div class="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<h3 class="text-sm font-bold text-white/90">Messages</h3>
|
||||||
|
<button
|
||||||
|
class="text-[10px] px-2.5 py-1 rounded bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
|
||||||
|
@click="showNewDM = !showNewDM"
|
||||||
|
>
|
||||||
|
{{ showNewDM ? 'Cancel' : 'New' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New DM input -->
|
||||||
|
<div v-if="showNewDM" class="space-y-2 mb-3">
|
||||||
|
<input
|
||||||
|
v-model="newContactPubkey"
|
||||||
|
type="text"
|
||||||
|
placeholder="Recipient hex pubkey or npub..."
|
||||||
|
class="w-full 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="text-[10px] px-2.5 py-1 rounded bg-white/5 text-white/60 hover:bg-white/10 transition-colors disabled:opacity-30"
|
||||||
|
:disabled="!newContactPubkey.trim()"
|
||||||
|
@click="startNewDM"
|
||||||
|
>
|
||||||
|
Start conversation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-1">
|
||||||
|
<div
|
||||||
|
v-if="!isLoggedIn"
|
||||||
|
class="flex items-center justify-center py-12"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-white/30">Sign in with Nostr to use DMs</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="isLoading"
|
||||||
|
class="flex items-center justify-center py-12"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-white/30">Loading messages...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="threads.length === 0"
|
||||||
|
class="flex items-center justify-center py-12"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-white/30">No messages yet</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="thread in threads"
|
||||||
|
:key="thread.contactPubkey"
|
||||||
|
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="selectContact(thread.contactPubkey)"
|
||||||
|
>
|
||||||
|
<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-accent/20 text-accent">
|
||||||
|
{{ thread.contactName.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 text-white/80">
|
||||||
|
{{ thread.contactName }}
|
||||||
|
</span>
|
||||||
|
<span v-if="thread.lastMessage" class="text-[9px] ml-auto shrink-0 text-white/20">
|
||||||
|
{{ formatTime(thread.lastMessage.created_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="thread.lastMessage" class="text-[11px] mt-1 text-white/40 truncate">
|
||||||
|
{{ thread.lastMessage.content }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||||
|
import { useNostrDMs } from '@/composables/useNostrDMs'
|
||||||
|
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||||
|
import { decodeNpub } from '@/utils/bech32'
|
||||||
|
|
||||||
|
const { threads, activeThread, activeContact, isLoading, loadDMs, sendDM, selectContact, clearActiveContact } = useNostrDMs()
|
||||||
|
const { pubkey, isLoggedIn } = useNostrIdentity()
|
||||||
|
|
||||||
|
const messageInput = ref('')
|
||||||
|
const isSending = ref(false)
|
||||||
|
const messagesRef = ref<HTMLElement | null>(null)
|
||||||
|
const showNewDM = ref(false)
|
||||||
|
const newContactPubkey = ref('')
|
||||||
|
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
const d = new Date(ts * 1000)
|
||||||
|
const now = new Date()
|
||||||
|
const diffDays = Math.floor((now.getTime() - d.getTime()) / 86400000)
|
||||||
|
if (diffDays === 0) return d.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
if (diffDays < 7) return d.toLocaleDateString('en', { weekday: 'short' })
|
||||||
|
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
if (!messageInput.value.trim() || isSending.value || !activeContact.value) return
|
||||||
|
isSending.value = true
|
||||||
|
const success = await sendDM(activeContact.value, messageInput.value.trim())
|
||||||
|
if (success) {
|
||||||
|
messageInput.value = ''
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
isSending.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (messagesRef.value) {
|
||||||
|
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNewDM() {
|
||||||
|
let hex = newContactPubkey.value.trim()
|
||||||
|
if (hex.startsWith('npub')) {
|
||||||
|
try {
|
||||||
|
hex = decodeNpub(hex)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hex.length === 64) {
|
||||||
|
selectContact(hex)
|
||||||
|
showNewDM.value = false
|
||||||
|
newContactPubkey.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeContact, async () => {
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDMs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
|
<!-- Sub-tab switcher -->
|
||||||
|
<div class="flex gap-1 px-4 pt-3 pb-1">
|
||||||
|
<button
|
||||||
|
v-for="tab in subTabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="text-[10px] px-2.5 py-1 rounded-md transition-all duration-150"
|
||||||
|
:class="activeSubTab === tab.id
|
||||||
|
? 'nav-tab-active'
|
||||||
|
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
|
||||||
|
@click="activeSubTab = tab.id"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DMs sub-tab -->
|
||||||
|
<NostrDMs v-if="activeSubTab === 'dms'" />
|
||||||
|
|
||||||
|
<!-- Feed sub-tab -->
|
||||||
|
<template v-else>
|
||||||
<div class="p-4 space-y-3 border-b border-white/[0.08]">
|
<div class="p-4 space-y-3 border-b border-white/[0.08]">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<h3 class="text-sm font-bold text-white/90">
|
<h3 class="text-sm font-bold text-white/90">
|
||||||
@@ -164,11 +184,13 @@
|
|||||||
<p class="text-sm text-white/30">No notes match your search</p>
|
<p class="text-sm text-white/30">No notes match your search</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
|
import NostrDMs from './NostrDMs.vue'
|
||||||
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'
|
||||||
|
|
||||||
@@ -177,6 +199,12 @@ defineEmits<{ selectNote: [note: NostrNote] }>()
|
|||||||
const { events: notes, isConnected, relayStates, connect, publishEvent } = useNostr()
|
const { events: notes, isConnected, relayStates, connect, publishEvent } = useNostr()
|
||||||
const { isLoggedIn, signEvent } = useNostrIdentity()
|
const { isLoggedIn, signEvent } = useNostrIdentity()
|
||||||
|
|
||||||
|
const activeSubTab = ref<'feed' | 'dms'>('feed')
|
||||||
|
const subTabs = [
|
||||||
|
{ id: 'feed' as const, label: 'Feed' },
|
||||||
|
{ id: 'dms' as const, label: 'Messages' },
|
||||||
|
]
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const activeKind = ref<number | null>(null)
|
const activeKind = ref<number | null>(null)
|
||||||
const showCompose = ref(false)
|
const showCompose = ref(false)
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useNostrIdentity } from './useNostrIdentity'
|
||||||
|
|
||||||
|
export interface DirectMessage {
|
||||||
|
id: string
|
||||||
|
fromPubkey: string
|
||||||
|
toPubkey: string
|
||||||
|
content: string
|
||||||
|
created_at: number
|
||||||
|
decrypted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DMThread {
|
||||||
|
contactPubkey: string
|
||||||
|
contactName: string
|
||||||
|
messages: DirectMessage[]
|
||||||
|
lastMessage: DirectMessage | null
|
||||||
|
unread: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = 'aiui-nostr-dms'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const STORE_NAME = 'messages'
|
||||||
|
|
||||||
|
const threads = ref<DMThread[]>([])
|
||||||
|
const activeContact = ref<string | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null
|
||||||
|
|
||||||
|
function openDMDB(): Promise<IDBDatabase> {
|
||||||
|
if (dbPromise) return dbPromise
|
||||||
|
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||||
|
store.createIndex('contact', 'contactPubkey', { unique: false })
|
||||||
|
store.createIndex('created_at', 'created_at', { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.onsuccess = () => resolve(req.result)
|
||||||
|
req.onerror = () => {
|
||||||
|
dbPromise = null
|
||||||
|
reject(req.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return dbPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredDM extends DirectMessage {
|
||||||
|
contactPubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncatePubkey(pk: string): string {
|
||||||
|
if (pk.length <= 12) return pk
|
||||||
|
return pk.slice(0, 8) + '...' + pk.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDM(msg: DirectMessage, myPubkey: string): Promise<void> {
|
||||||
|
const db = await openDMDB()
|
||||||
|
const contactPubkey = msg.fromPubkey === myPubkey ? msg.toPubkey : msg.fromPubkey
|
||||||
|
const record: StoredDM = { ...msg, contactPubkey }
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||||
|
tx.objectStore(STORE_NAME).put(record)
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllDMs(): Promise<StoredDM[]> {
|
||||||
|
const db = await openDMDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||||
|
const req = tx.objectStore(STORE_NAME).getAll()
|
||||||
|
req.onsuccess = () => resolve(req.result)
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildThreads(messages: StoredDM[]): DMThread[] {
|
||||||
|
const threadMap = new Map<string, DirectMessage[]>()
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const existing = threadMap.get(msg.contactPubkey) ?? []
|
||||||
|
existing.push(msg)
|
||||||
|
threadMap.set(msg.contactPubkey, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: DMThread[] = []
|
||||||
|
for (const [contactPubkey, msgs] of threadMap) {
|
||||||
|
msgs.sort((a, b) => a.created_at - b.created_at)
|
||||||
|
result.push({
|
||||||
|
contactPubkey,
|
||||||
|
contactName: truncatePubkey(contactPubkey),
|
||||||
|
messages: msgs,
|
||||||
|
lastMessage: msgs[msgs.length - 1] ?? null,
|
||||||
|
unread: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by latest message
|
||||||
|
result.sort((a, b) => (b.lastMessage?.created_at ?? 0) - (a.lastMessage?.created_at ?? 0))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNostrDMs() {
|
||||||
|
const { pubkey, isLoggedIn } = useNostrIdentity()
|
||||||
|
|
||||||
|
const activeThread = computed(() => {
|
||||||
|
if (!activeContact.value) return null
|
||||||
|
return threads.value.find(t => t.contactPubkey === activeContact.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadDMs() {
|
||||||
|
if (!isLoggedIn.value) return
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const all = await loadAllDMs()
|
||||||
|
threads.value = buildThreads(all)
|
||||||
|
} catch {
|
||||||
|
// IDB error
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDM(toPubkey: string, plaintext: string): Promise<boolean> {
|
||||||
|
if (!window.nostr?.nip04 || !pubkey.value) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encrypted = await window.nostr.nip04.encrypt(toPubkey, plaintext)
|
||||||
|
|
||||||
|
const unsigned = {
|
||||||
|
kind: 4,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', toPubkey]],
|
||||||
|
content: encrypted,
|
||||||
|
}
|
||||||
|
|
||||||
|
const signed = await window.nostr.signEvent(unsigned)
|
||||||
|
if (!signed) return false
|
||||||
|
|
||||||
|
// Store locally
|
||||||
|
const dm: DirectMessage = {
|
||||||
|
id: signed.id,
|
||||||
|
fromPubkey: pubkey.value,
|
||||||
|
toPubkey,
|
||||||
|
content: plaintext,
|
||||||
|
created_at: signed.created_at,
|
||||||
|
decrypted: true,
|
||||||
|
}
|
||||||
|
await saveDM(dm, pubkey.value)
|
||||||
|
|
||||||
|
// Broadcast to relays (reuse useNostr relay infra)
|
||||||
|
const { publishEvent } = await import('./useNostr').then(m => m.useNostr())
|
||||||
|
await publishEvent(signed)
|
||||||
|
|
||||||
|
// Refresh threads
|
||||||
|
const all = await loadAllDMs()
|
||||||
|
threads.value = buildThreads(all)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function receiveDM(eventId: string, fromPubkey: string, encryptedContent: string, createdAt: number): Promise<void> {
|
||||||
|
if (!window.nostr?.nip04 || !pubkey.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plaintext = await window.nostr.nip04.decrypt(fromPubkey, encryptedContent)
|
||||||
|
|
||||||
|
const dm: DirectMessage = {
|
||||||
|
id: eventId,
|
||||||
|
fromPubkey,
|
||||||
|
toPubkey: pubkey.value,
|
||||||
|
content: plaintext,
|
||||||
|
created_at: createdAt,
|
||||||
|
decrypted: true,
|
||||||
|
}
|
||||||
|
await saveDM(dm, pubkey.value)
|
||||||
|
|
||||||
|
const all = await loadAllDMs()
|
||||||
|
threads.value = buildThreads(all)
|
||||||
|
} catch {
|
||||||
|
// Decryption failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectContact(contactPubkey: string) {
|
||||||
|
activeContact.value = contactPubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveContact() {
|
||||||
|
activeContact.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
threads,
|
||||||
|
activeThread,
|
||||||
|
activeContact,
|
||||||
|
isLoading,
|
||||||
|
loadDMs,
|
||||||
|
sendDM,
|
||||||
|
receiveDM,
|
||||||
|
selectContact,
|
||||||
|
clearActiveContact,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,15 @@ export function encodeNpub(hexPubkey: string): string {
|
|||||||
return bech32Encode('npub', hexToBytes(hexPubkey))
|
return bech32Encode('npub', hexToBytes(hexPubkey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Decode an npub1... string to a hex public key */
|
||||||
|
export function decodeNpub(npubStr: string): string {
|
||||||
|
const decoded = bech32Decode(npubStr)
|
||||||
|
if (!decoded || decoded.hrp !== 'npub' || decoded.data.length !== 32) {
|
||||||
|
throw new Error('Invalid npub')
|
||||||
|
}
|
||||||
|
return bytesToHex(decoded.data)
|
||||||
|
}
|
||||||
|
|
||||||
export interface NIP19Decoded {
|
export interface NIP19Decoded {
|
||||||
type: 'npub' | 'note' | 'nevent' | 'nprofile' | 'unknown'
|
type: 'npub' | 'note' | 'nevent' | 'nprofile' | 'unknown'
|
||||||
hex: string
|
hex: string
|
||||||
|
|||||||
Reference in New Issue
Block a user