diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue
index 97e719fe..78f909c8 100644
--- a/packages/app/src/components/chat/ChatMessage.vue
+++ b/packages/app/src/components/chat/ChatMessage.vue
@@ -75,6 +75,14 @@
/>
+
+
+
+
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
})
diff --git a/packages/app/src/components/chat/NostrEmbed.vue b/packages/app/src/components/chat/NostrEmbed.vue
new file mode 100644
index 00000000..05b835aa
--- /dev/null
+++ b/packages/app/src/components/chat/NostrEmbed.vue
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+ {{ isProfile ? 'Profile' : 'Note' }} not found
+
+
+ {{ truncatedId }}
+
+
+
+
+
+
+
+ {{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
+
+
+ {{ note.authorName ?? 'anon' }}
+
+
+ {{ formatTime(note.created_at) }}
+
+
+
+ {{ note.content }}
+
+
+
+ nostr
+
+
+
+
+
+
+
+
+ {{ truncatedId.charAt(0).toUpperCase() }}
+
+
+
+ {{ truncatedId }}
+
+
+ nostr profile
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/composables/useNostr.ts b/packages/app/src/composables/useNostr.ts
index 665ec2a9..3c404e96 100644
--- a/packages/app/src/composables/useNostr.ts
+++ b/packages/app/src/composables/useNostr.ts
@@ -175,6 +175,55 @@ function disconnect() {
initialized = false
}
+function fetchNote(hexId: string, timeoutMs = 5000): Promise {
+ // 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,
}
}
diff --git a/packages/app/src/utils/bech32.ts b/packages/app/src/utils/bech32.ts
new file mode 100644
index 00000000..1c54c902
--- /dev/null
+++ b/packages/app/src/utils/bech32.ts
@@ -0,0 +1,126 @@
+const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
+const CHARSET_MAP: Record = {}
+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) }
+}