nostr-web-components
Version:
collection of web components that provide quick access to basic nostr things
240 lines (212 loc) • 7.34 kB
text/typescript
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://antiprimal.net'].map(normalizeURL)
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 = `
<style>
.wrapper {
position: relative;
}
ul {
margin: 0;
margin-top: -1px;
padding: 0;
background-color: white;
position: absolute;
left: 0;
border-bottom: 0;
display: none;
}
ul > li {
display: flex;
align-items: center;
cursor: pointer;
}
ul > li:hover {
background-color: rgba(0, 0, 0, 0.05);
}
ul > li > img {
height: 2rem;
width: 2rem;
}
ul > li > span {
margin: 0 0.5rem;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<div class="wrapper" part="wrapper">
<input type="search" part="input" />
<ul part="results">
<template>
<li part="item">
<img part="picture" />
<span part="name"></span>
<span part="nip05"></span>
</li>
</template>
</ul>
</div>
`
}
for (let input of this.queryPart<HTMLInputElement>('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')!
this.search(input.value.trim())
}
}
}, 1)
}
attributeChangedCallback(name: string, _old: string, value: string) {
if (name === 'relays') {
this.relays = splitComma(value).map(normalizeURL)
} else if (name === 'limit') {
this.limit = parseInt(value) || 10
} else if (name === 'placeholder') {
for (let input of this.queryPart<HTMLInputElement>('input')) {
input.setAttribute('placeholder', value)
}
} else if (name === 'value') {
for (let input of this.queryPart<HTMLInputElement>('input')) {
input.value = value
}
this.search(value.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 i = 0; i < results.children.length; i++) {
if (results.children[i].tagName === 'TEMPLATE') continue
results.removeChild(results.children[i])
}
results.style.display = 'block'
}
// check if q is a valid npub, nprofile, or hex pubkey
let pubkey: string | null = null
try {
if (q.startsWith('npub1') || q.startsWith('nprofile1')) {
const decoded = decode(q)
pubkey = decoded.data as string
} 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
this.dispatchEvent(
new CustomEvent('selected', {
bubbles: true,
detail: loadNostrUser({ pubkey, relays: this.relays }),
}),
)
return
}
} catch (e) {
// invalid format, continue with normal search
}
pool.subscribeManyEose(
this.relays,
{ search: q, limit: this.limit, kinds: [0] },
{
onevent: evt => {
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)
}
}
},
},
)
}, 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 i = 0; i < results.children.length; i++) {
if (results.children[i].tagName === 'TEMPLATE') continue
results.removeChild(results.children[i])
}
results.style.display = 'none'
}
this.value = pubkey
this.dispatchEvent(
new CustomEvent('selected', {
bubbles: true,
detail: nostrUserFromEvent(JSON.parse(item.dataset.event!)),
}),
)
}
*queryPart<H = HTMLElement>(name: string): Iterable<H> {
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)