// 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 : '/'
})
}