Oddbean new post about | logout
 First iteration of a standalone, very lite and naive #nostr client

https://github.com/talvasconcelos/litestr

To make it even more cypherpunk, just copy the code bellow to an HTML file and open it on your browser!

```
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="Nostr standalone local client to read notes (WIP)"
    />
    <title>Nostr Client</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
    />
  </head>

  <body>
    <header>
      <nav class="container-fluid">
        <ul>
          <li><strong>NOSTR Client</strong></li>
        </ul>
        <ul>
          <li>
            <button
              class="contrast"
              data-target="pubkey-modal"
              onclick="toggleModal(event)"
              id="add-pubkey"
            >
              Add Pubkey
            </button>
          </li>
        </ul>
      </nav>
    </header>
    <main class="container">
      <section id="notes"></section>
    </main>
    <!-- <footer>...</footer> -->

    <dialog id="pubkey-modal">
      <article>
        <header>
          <button
            aria-label="Close"
            rel="prev"
            data-target="pubkey-modal"
            onclick="toggleModal(event)"
          ></button>
          <h3>Add you Public Key</h3>
        </header>
        <input
          type="text"
          name="pubkey"
          placeholder="npub..."
          aria-label="Pubkey"
        />
        <footer>
          <button
            role="button"
            class="secondary"
            data-target="pubkey-modal"
            onclick="toggleModal(event)"
          >
            Cancel</button
          ><button
            autofocus
            data-target="pubkey-modal"
            onclick="toggleModal(event)"
          >
            Confirm
          </button>
        </footer>
      </article>
    </dialog>

    <script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
    <!-- <script src="js/index.js"></script> -->
    <script>
      const nostr = NostrTools
      const notesSection = document.getElementById('notes')

      const pool = new nostr.SimplePool()
      let relays = new Set([
        'wss://relay.nostr.band/',
        'wss://nostr-pub.wellorder.net/',
        'wss://relay.damus.io/'
      ])

      // timestamp a year ago
      let aYearAgo = 365 * 24 * 60 * 60
      let since = parseInt((Date.now() - aYearAgo) / 1000) || 0

      let pk = localStorage.getItem('pk') || null
      let npub = null
      let follows = new Set()
      let ev = new Map()
      let profiles = new Map()

      if (pk) {
        pk = sanitizePK(pk)
        getInitial().then(async () => {
          await getNotes()
          subscribeToRelays()
        })
      }

      async function getInitial() {
        console.log('Getting initial')
        notesSection.ariaBusy = true
        let events = await pool.querySync([...relays], {
          kinds: [0, 3, 10002],
          authors: [pk]
        })
        events.forEach(event => {
          if (event.kind == 0) {
            profiles.set(event.id, event)
          }
          if (event.kind == 3) {
            event.tags.forEach(tag => {
              if (tag[0] == ['p']) {
                follows.add(tag[1])
              }
            })
          }
          if (event.kind == 10002) {
            event.tags.forEach(tag => {
              if (tag[0] == ['r'] && tag[2] != 'write') {
                relays.add(tag[1])
              }
            })
          }
        })
        const _profiles = await pool.querySync([...relays], {
          kinds: [0],
          authors: [...follows]
        })
        _profiles.forEach(profile => {
          profiles.set(profile.pubkey, profile)
        })
        notesSection.ariaBusy = false
      }

      function subscribeToRelays() {
        const events = [...ev.values()] || []
        const _since =
          events.sort((a, b) => b.created_at - a.created_at)[0].created_at || 0
        console.log('Since:', since)
        pool.subscribeMany(
          Array.from(relays),
          [
            {
              authors: Array.from(follows),
              kinds: [1],
              since: _since
            }
          ],
          {
            onevent(event) {
              console.log('Got an event')
              if (event.kind == 1) {
                // check if the event is already in the map
                if (ev.has(event.id)) return

                ev.set(event.id, event)
                addNote(event)
              }
            }
          }
        )
      }

      async function getNotes() {
        console.log('Getting notes', since)
        notesSection.ariaBusy = true
        let events = await pool.querySync([...relays], {
          kinds: [1],
          authors: [...follows],
          since
        })
        events
          .sort((a, b) => b.created_at - a.created_at)
          .forEach(event => {
            ev.set(event.id, event)
          })
        notesSection.ariaBusy = false
        renderNotes()
      }

      function sanitizePK(pk) {
        if (pk.startsWith('npub')) {
          npub = pk
          let {type, data} = nostr.nip19.decode(npub)
          pk = data
        } else {
          npub = nostr.nip19.npubEncode(pk)
        }
        follows.add(pk) // follow self
        return pk
      }

      function renderNotes() {
        notesSection.innerHTML = ''
        ev.forEach(event => {
          if (event.kind === 1) {
            notesSection.appendChild(noteTemplate(event))
          }
        })
      }

      function addNote(event) {
        notesSection.prepend(noteTemplate(event))
      }

      // Modal
      const isOpenClass = 'modal-is-open'
      const openingClass = 'modal-is-opening'
      const closingClass = 'modal-is-closing'
      const scrollbarWidthCssVar = '--pico-scrollbar-width'
      const animationDuration = 400 // ms
      const inputPK = document.getElementsByName('pubkey')[0]
      let visibleModal = null

      // Toggle modal
      const toggleModal = event => {
        event.preventDefault()
        const modal = document.getElementById(
          event.currentTarget.dataset.target
        )
        if (!modal) return
        modal && (modal.open ? closeModal(modal) : openModal(modal))
      }

      // Open modal
      const openModal = modal => {
        const {documentElement: html} = document
        if (pk) {
          inputPK.value = pk
        }
        const scrollbarWidth = getScrollbarWidth()
        if (scrollbarWidth) {
          html.style.setProperty(scrollbarWidthCssVar, `${scrollbarWidth}px`)
        }
        html.classList.add(isOpenClass, openingClass)
        setTimeout(() => {
          visibleModal = modal
          html.classList.remove(openingClass)
        }, animationDuration)
        modal.showModal()
      }

      // Close modal
      const closeModal = modal => {
        visibleModal = null
        const {documentElement: html} = document
        html.classList.add(closingClass)
        if (inputPK.value !== '') {
          pk = sanitizePK(inputPK.value)
          localStorage.setItem('pk', pk)
          inputPK.value = ''
        }
        setTimeout(() => {
          html.classList.remove(closingClass, isOpenClass)
          html.style.removeProperty(scrollbarWidthCssVar)
          modal.close()
        }, animationDuration)
        pk &&
          Promise.resolve(getInitial()).then(async () => {
            await getNotes()
            subscribeToRelays()
          })
      }

      // Close with a click outside
      document.addEventListener('click', event => {
        if (visibleModal === null) return
        const modalContent = visibleModal.querySelector('article')
        const isClickInside = modalContent.contains(event.target)
        !isClickInside && closeModal(visibleModal)
      })

      // Close with Esc key
      document.addEventListener('keydown', event => {
        if (event.key === 'Escape' && visibleModal) {
          closeModal(visibleModal)
        }
      })

      // Get scrollbar width
      const getScrollbarWidth = () => {
        const scrollbarWidth =
          window.innerWidth - document.documentElement.clientWidth
        return scrollbarWidth
      }

      // Is scrollbar visible
      const isScrollbarVisible = () => {
        return document.body.scrollHeight > screen.height
      }

      // Templates
      const getProfile = id => {
        try {
          const profile = profiles.get(id)
          if (!profile) return id
          console.log('Profile:', profile)
          const content = JSON.parse(profile?.content)
          console.log('Content:', content)
          const name = content?.name || id
          return name
        } catch (error) {
          return id
        }
      }
      const noteTemplate = event => {
        let note = document.createElement('article')
        note.whiteSpace = 'preserve'
        note.innerHTML = `
          <header>
            ${getProfile(event.pubkey)}
          </header>
            ${event.content}
          <footer>
            <time datetime="${event.created_at}">${new Date(
          event.created_at * 1000
        ).toLocaleString()}</time>
            </footer>
      `
        return note
      }
    </script>
  </body>
</html>
```