OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface / static / composer.js

115 lines · javascript ref:
// The @-picker. A REAL FILE, not a template literal.
//
// ⛔ It lived in a `...` string and the regex `\s` lost its backslash on the way out, so the page
// shipped /(?:^|s)@.../ — it looked for a literal "s" before the at-sign and matched nothing. The
// script was on the page, the endpoint answered, and the list never appeared. A file has no
// escaping layer to lose anything in, and `static/anproto-client.js` was already the precedent.
//
// ⛔ PROGRESSIVE. It inserts `@alice` — ordinary text a person could have typed — and the daemon
// resolves it on the write path either way. With this file missing the composer still works; with
// it present it cannot produce a mention the server would not have produced anyway.

const ta = document.querySelector('.composer textarea')
const box = document.getElementById('at')
if (ta && box) {
  let items = [], sel = 0, at = -1

  const hide = () => { box.hidden = true; items = []; at = -1 }
  const draw = () => {
    if (!items.length) return hide()
    box.innerHTML = items.map((p, i) =>
      '<li' + (i === sel ? ' class="on"' : '') + '>@' + p.name +
      (p.label ? ' <span>' + p.label + '</span>' : '') + '</li>').join('')
    box.hidden = false
  }
  const take = (p) => {
    if (!p) return
    ta.value = ta.value.slice(0, at) + '@' + p.name + ' ' + ta.value.slice(ta.selectionStart)
    const pos = at + p.name.length + 2
    ta.focus(); ta.setSelectionRange(pos, pos)
    hide()
  }

  ta.addEventListener('input', async () => {
    const upto = ta.value.slice(0, ta.selectionStart)
    // The token being typed right now: an @ that starts a word, with no space since.
    const m = /(?:^|\s)@([a-z0-9._-]*)$/i.exec(upto)
    if (!m) return hide()
    at = ta.selectionStart - m[1].length - 1
    if (m[1].length < 1) return hide()
    try {
      const r = await fetch('/api/mentions?q=' + encodeURIComponent(m[1]))
      items = (await r.json()).people || []
      sel = 0
      draw()
    } catch { hide() }
  })

  ta.addEventListener('keydown', (e) => {
    if (box.hidden || !items.length) return
    if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % items.length; draw() }
    else if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % items.length; draw() }
    else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); take(items[sel]) }
    else if (e.key === 'Escape') hide()
  })

  box.addEventListener('mousedown', (e) => {
    const li = e.target.closest('li')
    if (li) { e.preventDefault(); take(items[[...box.children].indexOf(li)]) }
  })
  ta.addEventListener('blur', () => setTimeout(hide, 120))
}

// ── the mip pyramid ───────────────────────────────────────────────────────────────────────────
//
// ⛔ PROGRESSIVE, like the picker above it. With this block missing the form posts the original file
// as a single part and the daemon stores one level — which is exactly today's behaviour and stays
// correct. With it present, the browser also renders the smaller levels so a bridge can SELECT one
// that fits its destination instead of the picture being withheld for being 40 KB over a ceiling.
//
// ⛔ THE BASE LEVEL IS THE AUTHOR'S OWN BYTES, uploaded untouched. Only the derived levels are
// re-encoded, and they are marked as authored because the author's own machine made them at compose
// time — a level a BRIDGE generates later is `derived:` and that distinction is §6.6.4's, not ours.
import { planLevels, renderLevel } from './levels.js'

const form = document.querySelector('form.composer')
const input = form && form.querySelector('input[type=file][name=file]')

if (form && input && window.createImageBitmap && window.FormData) {
  form.addEventListener('submit', async (ev) => {
    const file = input.files && input.files[0]
    if (!file || !/^image\//.test(file.type)) return // let the plain form post handle it

    ev.preventDefault()
    const fd = new FormData(form)

    try {
      const bitmap = await createImageBitmap(file)
      const plan = planLevels(bitmap.width, bitmap.height)
      // The base's real dimensions travel too — bsky lays an image out from aspectRatio, and without
      // them every favela picture has been laid out from a guess.
      fd.set('file-w', String(bitmap.width))
      fd.set('file-h', String(bitmap.height))

      let n = 0
      for (const level of plan) {
        const out = await renderLevel(bitmap, level.edge, level.quality)
        // ⚠ A level BIGGER than the source is not a level. Re-encoding can inflate a small PNG, and
        // shipping that would cost bytes and buy nothing.
        if (!out || out.blob.size >= file.size) continue
        fd.append('level-' + n, out.blob, level.name + '.jpg')
        fd.set('level-' + n + '-w', String(out.width))
        fd.set('level-' + n + '-h', String(out.height))
        n++
      }
      if (typeof bitmap.close === 'function') bitmap.close()
    } catch {
      // ⛔ A failure here must not lose the post. Fall through and send what the form already holds:
      // the original file, one level, which is what a browser without this code sends anyway.
    }

    const res = await fetch(form.action || '/post', { method: 'POST', body: fd, redirect: 'follow' })
    window.location = res.redirected ? res.url : '/'
  })
}