// 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() }
})
}