feat(app): add nostr social embeds with bech32 NIP-19 decoding

Create NostrEmbed.vue component that renders nostr:note1, nostr:npub1,
and nostr:nevent1 URIs as rich embedded cards. Add bech32 decoder
utility with NIP-19 TLV support for nevent/nprofile. Extend useNostr
with fetchNote() for single-note relay lookups. ChatMessage.vue now
detects and strips nostr URIs, rendering them as inline embed cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 20:42:11 +00:00
co-authored by Claude Opus 4.6
parent 557f9e6220
commit 771e13aaf2
4 changed files with 366 additions and 0 deletions
@@ -75,6 +75,14 @@
/>
</div>
<div v-if="nostrUris.length > 0" class="mt-3 space-y-1.5" @click.stop>
<NostrEmbed
v-for="uri in nostrUris"
:key="uri"
:uri="uri"
/>
</div>
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineNewsLinks"
@@ -186,6 +194,7 @@ import SongCard from '@/components/content/SongCard.vue'
import PodcastCard from '@/components/content/PodcastCard.vue'
import PlaceCard from '@/components/content/PlaceCard.vue'
import NewsCard from '@/components/content/NewsCard.vue'
import NostrEmbed from '@/components/chat/NostrEmbed.vue'
const props = withDefaults(
defineProps<{
@@ -224,6 +233,13 @@ const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
const inlineWebsitesLinks = computed(() => inlineContent.value.websitesLinks ?? [])
const inlineMagazineSections = computed(() => inlineContent.value.magazineSections ?? [])
const NOSTR_URI_RE = /nostr:(note1[a-z0-9]{58}|npub1[a-z0-9]{58}|nevent1[a-z0-9]+|nprofile1[a-z0-9]+)/g
const nostrUris = computed(() => {
if (isUser.value) return []
const matches = props.message.content.match(NOSTR_URI_RE)
return matches ? [...new Set(matches)] : []
})
const isCodeResponse = computed(() =>
!isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code'
)
@@ -261,6 +277,7 @@ const displayText = computed(() => {
if (isUser.value) return props.message.content
let text = stripContentTags(props.message.content)
if (inlineNewsLinks.value.length > 0 || inlineWebsitesLinks.value.length > 0) text = stripMarkdownLinks(text)
if (nostrUris.value.length > 0) text = text.replace(NOSTR_URI_RE, '').replace(/\n{3,}/g, '\n\n').trim()
return text
})
@@ -0,0 +1,173 @@
<template>
<div
class="rounded-xl p-3 transition-all duration-150"
:class="isDark
? 'bg-purple-500/10 border border-purple-500/20'
: 'bg-purple-50 border border-purple-200'"
>
<!-- Loading -->
<div v-if="loading" class="flex items-center gap-2">
<div
class="w-6 h-6 rounded-full animate-pulse"
:class="isDark ? 'bg-purple-500/20' : 'bg-purple-200'"
/>
<div class="flex-1 space-y-1">
<div
class="h-3 w-24 rounded animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-gray-200'"
/>
<div
class="h-2 w-40 rounded animate-pulse"
:class="isDark ? 'bg-white/5' : 'bg-gray-100'"
/>
</div>
</div>
<!-- Error -->
<div v-else-if="error" class="flex items-center gap-2">
<span class="text-[10px]" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ isProfile ? 'Profile' : 'Note' }} not found
</span>
<span
class="text-[9px] font-mono truncate"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400'"
>
{{ truncatedId }}
</span>
</div>
<!-- Note content -->
<div v-else-if="note">
<div class="flex items-center gap-2 mb-1.5">
<div
class="w-6 h-6 rounded-full shrink-0 flex items-center justify-center text-[9px] font-bold"
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'"
>
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
<span
class="text-[11px] font-semibold truncate"
:class="isDark ? 'text-white/70' : 'text-gray-700'"
>
{{ note.authorName ?? 'anon' }}
</span>
<span
class="text-[9px] ml-auto shrink-0"
:class="isDark ? 'text-white/20' : 'text-gray-300'"
>
{{ formatTime(note.created_at) }}
</span>
</div>
<p
class="text-[11px] leading-relaxed line-clamp-4"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
>
{{ note.content }}
</p>
<div class="flex items-center gap-2 mt-1.5">
<span
class="text-[9px] font-mono"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400/60'"
>
nostr
</span>
</div>
</div>
<!-- Profile card (npub) -->
<div v-else-if="isProfile">
<div class="flex items-center gap-2">
<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'"
>
{{ truncatedId.charAt(0).toUpperCase() }}
</div>
<div>
<span
class="text-[11px] font-mono block"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
>
{{ truncatedId }}
</span>
<span
class="text-[9px]"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400/60'"
>
nostr profile
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useNostr, type NostrNote } from '@/composables/useNostr'
import { decodeNIP19 } from '@/utils/bech32'
const props = defineProps<{
uri: string
}>()
const { isDark } = useTheme()
const { connect, fetchNote: fetchFromRelay } = useNostr()
const note = ref<NostrNote | null>(null)
const loading = ref(false)
const error = ref(false)
const decoded = computed(() => {
const raw = props.uri.replace(/^nostr:/, '')
return decodeNIP19(raw)
})
const isProfile = computed(() => decoded.value?.type === 'npub' || decoded.value?.type === 'nprofile')
const truncatedId = computed(() => {
const hex = decoded.value?.hex ?? ''
if (hex.length <= 12) return hex
return hex.slice(0, 8) + '...' + hex.slice(-4)
})
function formatTime(ts: number): string {
const diff = Math.floor(Date.now() / 1000 - ts)
if (diff < 60) return 'now'
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
return `${Math.floor(diff / 86400)}d`
}
onMounted(async () => {
if (!decoded.value) {
error.value = true
return
}
// For profiles, just show the card with no fetch needed
if (isProfile.value) return
// For notes/events, fetch from relay
const hexId = decoded.value.hex
if (!hexId) {
error.value = true
return
}
loading.value = true
connect()
// Small delay to allow relay connections to establish
await new Promise(r => setTimeout(r, 500))
const result = await fetchFromRelay(hexId)
loading.value = false
if (result) {
note.value = result
} else {
error.value = true
}
})
</script>
+50
View File
@@ -175,6 +175,55 @@ function disconnect() {
initialized = false
}
function fetchNote(hexId: string, timeoutMs = 5000): Promise<NostrNote | null> {
// Check if already cached
const cached = events.value.find(e => e.id === hexId)
if (cached) return Promise.resolve(cached)
return new Promise((resolve) => {
const subId = generateSubId()
let resolved = false
const timer = setTimeout(() => {
if (!resolved) { resolved = true; resolve(null) }
}, timeoutMs)
// Try the first connected relay
const relay = relays.find(r => r.connected && r.ws)
if (!relay?.ws) {
clearTimeout(timer)
resolve(null)
return
}
const handler = (msg: MessageEvent) => {
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
const note: NostrNote = {
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 (!resolved) { resolved = true; clearTimeout(timer); resolve(note) }
relay.ws?.removeEventListener('message', handler)
}
if (Array.isArray(data) && data[0] === 'EOSE' && data[1] === subId) {
if (!resolved) { resolved = true; clearTimeout(timer); resolve(null) }
relay.ws?.removeEventListener('message', handler)
}
} catch { /* skip */ }
}
relay.ws.addEventListener('message', handler)
relay.ws.send(JSON.stringify(['REQ', subId, { ids: [hexId], limit: 1 }]))
})
}
export function useNostr() {
onUnmounted(() => {
// Clean up on component unmount
@@ -187,5 +236,6 @@ export function useNostr() {
relayStates,
connect,
disconnect,
fetchNote,
}
}
+126
View File
@@ -0,0 +1,126 @@
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
const CHARSET_MAP: Record<string, number> = {}
for (let i = 0; i < CHARSET.length; i++) CHARSET_MAP[CHARSET[i]] = i
function polymod(values: number[]): number {
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
let chk = 1
for (const v of values) {
const b = chk >> 25
chk = ((chk & 0x1ffffff) << 5) ^ v
for (let i = 0; i < 5; i++) {
if ((b >> i) & 1) chk ^= GEN[i]
}
}
return chk
}
function hrpExpand(hrp: string): number[] {
const ret: number[] = []
for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) >> 5)
ret.push(0)
for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) & 31)
return ret
}
function convertBits(data: number[], fromBits: number, toBits: number, pad: boolean): number[] | null {
let acc = 0
let bits = 0
const ret: number[] = []
const maxv = (1 << toBits) - 1
for (const value of data) {
if (value < 0 || value >> fromBits) return null
acc = (acc << fromBits) | value
bits += fromBits
while (bits >= toBits) {
bits -= toBits
ret.push((acc >> bits) & maxv)
}
}
if (pad) {
if (bits > 0) ret.push((acc << (toBits - bits)) & maxv)
} else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) {
return null
}
return ret
}
export interface Bech32Decoded {
hrp: string
data: Uint8Array
}
export function bech32Decode(str: string): Bech32Decoded | null {
const lower = str.toLowerCase()
const sepPos = lower.lastIndexOf('1')
if (sepPos < 1 || sepPos + 7 > lower.length || lower.length > 90) return null
const hrp = lower.slice(0, sepPos)
const dataChars = lower.slice(sepPos + 1)
const values: number[] = []
for (const c of dataChars) {
const v = CHARSET_MAP[c]
if (v === undefined) return null
values.push(v)
}
if (polymod([...hrpExpand(hrp), ...values]) !== 1) return null
const data5bit = values.slice(0, -6)
const bytes = convertBits(data5bit, 5, 8, false)
if (!bytes) return null
return { hrp, data: new Uint8Array(bytes) }
}
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}
export interface NIP19Decoded {
type: 'npub' | 'note' | 'nevent' | 'nprofile' | 'unknown'
hex: string
relays?: string[]
}
export function decodeNIP19(bech32Str: string): NIP19Decoded | null {
const decoded = bech32Decode(bech32Str)
if (!decoded) return null
const { hrp, data } = decoded
if (hrp === 'npub' && data.length === 32) {
return { type: 'npub', hex: bytesToHex(data) }
}
if (hrp === 'note' && data.length === 32) {
return { type: 'note', hex: bytesToHex(data) }
}
// TLV decoding for nevent/nprofile
if (hrp === 'nevent' || hrp === 'nprofile') {
let hex = ''
const relays: string[] = []
let i = 0
while (i < data.length) {
const tag = data[i]
const len = data[i + 1]
if (len === undefined) break
const value = data.slice(i + 2, i + 2 + len)
if (tag === 0) hex = bytesToHex(value)
if (tag === 1) relays.push(new TextDecoder().decode(value))
i += 2 + len
}
return {
type: hrp === 'nevent' ? 'nevent' : 'nprofile',
hex,
relays: relays.length > 0 ? relays : undefined,
}
}
return { type: 'unknown', hex: bytesToHex(data) }
}