import { normalizeURL } from '@nostr/tools/utils'
import { decode } from '@nostr/tools/nip19'
import { nostrUserFromEvent, loadNostrUser } from '@nostr/gadgets/metadata'
import { debounce, handleImageError, splitComma, transparentPixel } from './utils.js'
import { pool } from './nostr.js'
export default class NostrUserSearch extends HTMLElement {
static observedAttributes = ['relays', 'limit', 'placeholder', 'value']
root: HTMLDivElement
limit = 10
value: string | undefined
relays: string[] = ['wss://relay.vertexlab.io', 'wss://search.nostrarchives.com'].map(normalizeURL)
ready = false
constructor() {
super()
this.attachShadow({ mode: 'open' })
const { shadowRoot } = this
this.root = document.createElement('div')
shadowRoot!.appendChild(this.root)
}
connectedCallback() {
this.style.display = 'inline-block'
this.style.width = 'fit-content'
this.style.height = 'fit-content'
setTimeout(() => {
let template = this.getAttribute('template')
? (document.getElementById(this.getAttribute('template')!) as HTMLTemplateElement)
: null
if (template) {
this.root.appendChild(template.content.cloneNode(true))
} else {
this.root.innerHTML = `
`
}
for (let input of this.queryPart('input')) {
// setup input listener
input.oninput = ev => this.search((ev.target as HTMLInputElement).value.trim())
// apply any attributes that were set before the template was loaded
if (this.hasAttribute('placeholder')) {
input.setAttribute('placeholder', this.getAttribute('placeholder')!)
}
if (this.hasAttribute('value')) {
input.value = this.getAttribute('value')!
}
}
}, 1)
let valueAttr = this.getAttribute('value')
if (valueAttr) {
this.search(valueAttr.trim())
}
this.ready = true
}
attributeChangedCallback(name: string, _old: string, value: string | null) {
if (name === 'relays') {
this.relays = splitComma(value).map(normalizeURL)
} else if (name === 'limit') {
this.limit = parseInt(value || '') || 10
} else if (name === 'placeholder') {
if (!this.ready) return
for (let input of this.queryPart('input')) {
input.setAttribute('placeholder', value || '')
}
} else if (name === 'value') {
if (!this.ready) return
let q = value || ''
for (let input of this.queryPart('input')) {
input.value = q
}
this.search(q.trim())
}
}
search: (_: string) => void = debounce(async (q: string) => {
for (let results of this.queryPart('results')) {
if (q.length < 2) {
results.style.display = 'none'
return
}
for (let child of Array.from(results.children)) {
if (child.tagName === 'TEMPLATE') continue
child.remove()
}
results.style.display = 'block'
}
// show loader
for (let loader of this.queryPart('loader')) {
loader.hidden = false
}
// check if q is a valid npub, nprofile, or hex pubkey
let pubkey: string | null = null
try {
if (q.startsWith('nostr:')) q = q.substring(6)
if (q.startsWith('npub1') || q.startsWith('nprofile1')) {
const decoded = decode(q)
if (decoded.type === 'npub') {
pubkey = decoded.data as string
} else if (decoded.type === 'nprofile') {
pubkey = (decoded.data as { pubkey: string }).pubkey
}
} else if (/^[0-9a-fA-F]{64}$/.test(q)) {
pubkey = q.toLowerCase()
}
if (pubkey) {
// valid pubkey found, fetch metadata and fire selected event
this.value = pubkey
let nu = await loadNostrUser({ pubkey, relays: this.relays })
this.dispatchEvent(
new CustomEvent('selected', {
bubbles: true,
detail: nu,
}),
)
return
}
} catch (e) {
// invalid format, continue with normal search
}
pool.subscribeManyEose(
this.relays,
{ search: q, limit: this.limit, kinds: [0] },
{
onevent: evt => {
// hide loader when first event arrives
for (let loader of this.queryPart('loader')) {
loader.hidden = true
}
let nu = nostrUserFromEvent(evt)
for (let results of this.queryPart('results')) {
const template = results.querySelector('template') as HTMLTemplateElement
if (!template) continue
const item = template.content.cloneNode(true) as DocumentFragment
const itemElement = item.querySelector('[part="item"]') as HTMLElement
if (itemElement) {
itemElement.title = nu.npub
itemElement.onclick = this.handleItemClicked.bind(this)
itemElement.dataset.event = JSON.stringify(evt)
// set picture
const pic = item.querySelector('[part="picture"]') as HTMLImageElement
if (pic) {
pic.onerror = handleImageError
pic.src = nu.image || transparentPixel
}
// set name
const name = item.querySelector('[part="name"]') as HTMLElement
if (name) {
name.textContent = nu.shortName
}
// Set nip05
const nip05 = item.querySelector('[part="nip05"]') as HTMLElement
if (nip05 && nu.metadata.nip05) {
nip05.textContent = nu.metadata.nip05
}
results.appendChild(item)
}
}
},
},
)
// hide loader after timeout (fallback in case no events arrive)
setTimeout(() => {
for (let loader of this.queryPart('loader')) {
loader.hidden = true
}
}, 3000)
}, 450)
handleItemClicked(ev: MouseEvent) {
let item = ev.currentTarget! as HTMLLIElement
let npub = item.title
let pubkey = decode(npub).data as string
for (let results of this.queryPart('results')) {
for (let child of Array.from(results.children)) {
if (child.tagName === 'TEMPLATE') continue
child.remove()
}
results.style.display = 'none'
}
this.value = pubkey
this.dispatchEvent(
new CustomEvent('selected', {
bubbles: true,
detail: nostrUserFromEvent(JSON.parse(item.dataset.event!)),
}),
)
}
*queryPart(name: string): Iterable {
let slotted = this.querySelectorAll(`[part="${name}"]`)
for (let i = 0; i < slotted.length; i++) {
yield slotted[i] as H
}
let templated = this.root.querySelectorAll(`[part="${name}"]`)
for (let i = 0; i < templated.length; i++) {
yield templated[i] as H
}
}
}
window.customElements.define('nostr-user-search', NostrUserSearch)