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 @@ + + + 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) } +}