OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / web / static / compose.js

169 lines · javascript ref:
// Composer image attachments — progressive enhancement.
//
// The composer is a plain <form method=POST action=/api/post> that works with NO JS. This script only
// ADDS the ability to attach images: it reveals the picker, uploads each file to /api/upload (which
// returns an atproto blob ref), and — only when there is at least one image — submits the post as JSON
// so the refs can travel. With no images selected the native form submit is left completely alone, so
// the no-JS path is never regressed.
//
// bsky caps app.bsky.embed.images at 4 and REJECTS a longer array outright, so the cap is enforced here
// too: a record the relay refuses would federate as nothing while looking fine locally.

import { MAX_BYTES, resizePlan, aspectRatioOf } from './resize-plan.js'

const MAX_IMAGES = 4

// Shrink a picture to something the lexicon accepts, and report the dimensions it ended up with.
//
// ⛔ REFUSING WAS THE BUG. This used to alert "the limit is 1024 KB" and skip the file — half the real
// ceiling, and hopeless once the phone's composer could open the camera on 2026-08-18, because a phone
// camera JPEG is 3–12 MB. Every photo taken through favela bounced. The fix is not a bigger number; it
// is doing what every other client does and resizing the picture.
//
// ⚠ Re-encodes to JPEG, so transparency is lost on a PNG large enough to need shrinking. Accepted: the
// byte ceiling is a hard protocol limit and a >2 MB PNG is a photo or a screenshot, not an icon. Images
// that already fit are passed through UNTOUCHED, so nothing small is ever re-encoded.
async function prepare (file) {
  const bitmap = await createImageBitmap(file)
  const { width, height } = bitmap
  const plan = resizePlan({ width, height, bytes: file.size, type: file.type })
  if (!plan.needed) { bitmap.close(); return { blob: file, width, height } }

  const canvas = document.createElement('canvas')
  canvas.width = plan.width
  canvas.height = plan.height
  canvas.getContext('2d').drawImage(bitmap, 0, 0, plan.width, plan.height)
  bitmap.close()

  // Step the quality down only as far as needed. Stops at the first size that fits, so a picture barely
  // over the line keeps almost all of its quality.
  let out = null
  for (const q of [0.9, 0.82, 0.72, 0.6, 0.45]) {
    out = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', q))
    if (out && out.size <= MAX_BYTES) break
  }
  return { blob: out || file, width: plan.width, height: plan.height }
}

const form = document.querySelector('form.composer')
if (form) {
  const controls = document.getElementById('img-controls')
  const picker = document.getElementById('post-image')
  const preview = document.getElementById('img-preview')
  const attached = [] // { blob, alt, name, url }

  if (controls) controls.hidden = false // only shown once JS is confirmed present

  const draw = () => {
    if (!preview) return
    preview.innerHTML = ''
    attached.forEach((a, i) => {
      const wrap = document.createElement('span')
      wrap.className = 'thumb'
      const im = document.createElement('img')
      im.src = a.url; im.alt = a.name
      const alt = document.createElement('input')
      alt.type = 'text'; alt.placeholder = 'alt text (describe the image)'; alt.value = a.alt || ''
      alt.addEventListener('input', () => { a.alt = alt.value })
      const rm = document.createElement('button')
      rm.type = 'button'; rm.textContent = '✕'; rm.title = 'remove'
      rm.addEventListener('click', () => { URL.revokeObjectURL(a.url); attached.splice(i, 1); draw() })
      wrap.append(im, alt, rm)
      preview.append(wrap)
    })
  }

  if (picker) {
    picker.addEventListener('change', async () => {
      const files = [...(picker.files || [])]
      picker.value = ''
      for (const f of files) {
        if (attached.length >= MAX_IMAGES) { alert(`bsky allows at most ${MAX_IMAGES} images per post`); break }
        try {
          // Resize BEFORE the size check, so the check is a last resort rather than the policy.
          let up = f
          let ratio = null
          try {
            const prepared = await prepare(f)
            up = prepared.blob
            ratio = aspectRatioOf(prepared.width, prepared.height)
          } catch (e) {
            alert(`could not read ${f.name}: ${e.message}`)
            continue
          }
          // Only reachable if even the lowest quality could not fit — say so honestly rather than
          // blaming the file for being large.
          if (up.size > MAX_BYTES) {
            alert(`${f.name} is still ${Math.round(up.size / 1024)} KB after resizing — the limit is ${Math.round(MAX_BYTES / 1024)} KB`)
            continue
          }
          const res = await fetch('/api/upload', { method: 'POST', headers: { 'content-type': up.type || f.type || 'application/octet-stream' }, body: up })
          const body = await res.json().catch(() => ({}))
          if (!res.ok) { alert(body.error || `upload failed (${res.status})`); continue }
          // aspectRatio is what bsky lays the image out with. writer.js has always accepted it
          // (`images = [{ blob, alt?, aspectRatio? }]`) and this client never sent it, so every favela
          // image has been laid out from a guess.
          attached.push({ blob: body.blob, alt: '', name: f.name, url: URL.createObjectURL(up), aspectRatio: ratio })
          draw()
        } catch (e) { alert('upload failed: ' + e.message) }
      }
    })
  }

  form.addEventListener('submit', async (ev) => {
    if (!attached.length) return // no images -> leave the native no-JS form submit untouched
    ev.preventDefault()
    const text = form.querySelector('textarea[name=text]').value
    const scope = form.querySelector('select[name=scope]').value
    try {
      const res = await fetch('/api/post', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ text, scope, images: attached.map((a) => ({ blob: a.blob, alt: a.alt, ...(a.aspectRatio ? { aspectRatio: a.aspectRatio } : {}) })) })
      })
      const body = await res.json().catch(() => ({}))
      if (!res.ok) { alert(body.error || `post failed (${res.status})`); return }
      attached.forEach((a) => URL.revokeObjectURL(a.url))
      attached.length = 0
      window.location.href = '/' // same destination the no-JS form redirect lands on
    } catch (e) { alert('post failed: ' + e.message) }
  })
}

// ── character budget ─────────────────────────────────────────────────────────────────────────────
//
// atproto caps a post at 300 GRAPHEMES, not 300 JS string units. Without a counter the limit is
// invisible until the PDS rejects the write — after the user has typed the whole thing. Counting in
// graphemes matters: an emoji or a family sequence is one character to a reader and several UTF-16
// code units to `.length`, so a naive count tells people they are over when they are not.
const ta = document.querySelector('.composer textarea[name="text"]')
const cc = document.getElementById('charcount')
const postBtn = document.getElementById('post-btn')
const LIMIT = 300

const seg = (typeof Intl !== 'undefined' && Intl.Segmenter) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null
// [...str] at least splits on code POINTS (so astral characters count once) — still wrong for combining
// sequences, but never wrong in the direction that blocks a legal post.
function graphemes (s) { return seg ? [...seg.segment(s)].length : [...String(s)].length }

function updateCount () {
  if (!ta || !cc) return
  const n = graphemes(ta.value)
  const left = LIMIT - n
  // Stay quiet until it is nearly relevant — a counter that is always shouting is ignored.
  cc.textContent = left > 50 ? '' : String(left)
  cc.classList.toggle('over', left < 0)
  if (postBtn) postBtn.disabled = left < 0 || ta.value.trim().length === 0
}

if (ta) {
  ta.addEventListener('input', updateCount)
  updateCount()
  // Guard the no-JS-shaped submit path too: with the button disabled this is belt-and-braces, but a
  // form can still be submitted with Enter from some controls.
  const form = ta.closest('form')
  if (form) form.addEventListener('submit', (e) => {
    if (graphemes(ta.value) > LIMIT) { e.preventDefault(); updateCount() }
  })
}