// Avatar upload — progressive enhancement for /settings.
//
// This exists because a lost avatar is otherwise UNRECOVERABLE: the blob bytes are gone from every
// node (cdn 404, get-blob null, blobs/ empty), so nothing can restore it except the owner re-uploading.
// Doing so also lands the first blob in the content-addressed OverlayBlobStore, so the migration off
// the legacy Hyperbee blob store happens as a side effect.
//
// The bytes are POSTed RAW (no multipart): the server reads them binary-safe and derives the mime from
// magic bytes, so there is no parser and no header to trust. favela never holds a key — it relays to
// the local hpp, which stores the blob and signs the profile update.
const file = document.getElementById('avatar-file')
const status = document.getElementById('avatar-status')
const img = document.querySelector('.avatar')
const MAX = 1024 * 1024
function say (msg, cls) {
if (!status) return
status.textContent = msg
status.className = cls || ''
}
if (file) {
file.addEventListener('change', async () => {
const f = file.files && file.files[0]
if (!f) return
// Check client-side too so the user gets an instant, specific message instead of a 413 round-trip.
if (f.size > MAX) { say(`that image is ${Math.round(f.size / 1024)} KB — the limit is 1024 KB`, 'err'); file.value = ''; return }
say('uploading…')
try {
const res = await fetch('/api/avatar', {
method: 'POST',
headers: { 'content-type': f.type || 'application/octet-stream' },
body: f
})
const body = await res.json().catch(() => ({}))
if (!res.ok) { say(body.error || `upload failed (${res.status})`, 'err'); return }
say('avatar updated — your hpp signed the profile record', 'ok')
// Show the new image immediately. Blobs are content-addressed and served immutable, so the new
// cid is a genuinely different URL — no cache-busting hack needed.
if (img && body.cid) {
const url = `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(body.did)}&cid=${encodeURIComponent(body.cid)}`
if (img.tagName === 'IMG') { img.src = url } else {
const n = document.createElement('img')
n.className = 'avatar'; n.src = url; n.alt = 'current avatar'
img.replaceWith(n)
}
}
} catch (e) {
say('upload failed: ' + e.message, 'err')
} finally {
file.value = ''
}
})
}
// ── Devices: mint a join code, and revoke/restore paired browsers ────────────────────────────────
//
// `favela-web pair` could already mint an invite; the browser could only CONSUME one. So adding a
// second device meant shell access to the box running favela-web — the wrong requirement for "sign in
// on my phone". This closes the loop entirely in the UI: generate here, paste at /login there.
//
// The list itself is server-rendered, so with JS off you can still SEE your devices; only the two
// actions live here. Same posture as the avatar upload above.
const devStatus = document.getElementById('dev-status')
const pairBtn = document.getElementById('pair-new')
const pairOut = document.getElementById('pair-out')
const pairUrl = document.getElementById('pair-url')
const pairCode = document.getElementById('pair-code')
const pairCopy = document.getElementById('pair-copy')
const pairLabel = document.getElementById('pair-label')
function devSay (msg, cls) {
if (!devStatus) return
devStatus.textContent = msg
devStatus.className = cls || ''
}
if (pairBtn) {
pairBtn.addEventListener('click', async () => {
pairBtn.disabled = true
devSay('minting a join code — your hpp is signing it…')
try {
const res = await fetch('/api/devices/pair', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ label: pairLabel ? pairLabel.value : '' })
})
const body = await res.json().catch(() => ({}))
if (!res.ok) { devSay(body.error || `could not mint a join code (${res.status})`, 'err'); return }
pairCode.textContent = body.inviteHex
pairUrl.textContent = body.loginUrl
pairUrl.href = body.loginUrl
pairOut.hidden = false
// State the expiry as a duration rather than a timestamp — the code dies on first use OR at the
// TTL, and "5 minutes" is the fact that changes what the user does next.
const mins = Math.max(1, Math.round((body.expiresAt - Date.now()) / 60000))
devSay(`join code ready — valid for ~${mins} min, or until it is used once`, 'ok')
} catch (e) {
devSay('could not mint a join code: ' + e.message, 'err')
} finally {
pairBtn.disabled = false
}
})
}
if (pairCopy) {
pairCopy.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(pairCode.textContent || '')
devSay('join code copied', 'ok')
} catch {
// Clipboard is permission-gated and absent over plain http on some browsers. The code is on the
// page either way, so say so rather than failing silently.
devSay('could not copy — select the code above and copy it manually', 'err')
}
})
}
// Revoke/restore. Delegated from the list so it keeps working if rows are ever re-rendered.
document.querySelectorAll('.dev .devbtn').forEach((btn) => {
btn.addEventListener('click', async () => {
const row = btn.closest('.dev')
const subKey = row && row.dataset.subkey
if (!subKey) return
const restore = btn.dataset.action === 'restore'
btn.disabled = true
devSay(restore ? 'restoring…' : 'revoking…')
try {
const res = await fetch('/api/devices/revoke', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ subKey, restore })
})
const body = await res.json().catch(() => ({}))
if (!res.ok) { devSay(body.error || `failed (${res.status})`, 'err'); return }
// Flip the row in place. A revoke is persistent + replicated, so the next page load agrees —
// this is just avoiding a reload, not inventing state.
row.classList.toggle('revoked', !restore)
btn.dataset.action = restore ? 'revoke' : 'restore'
btn.textContent = restore ? 'revoke' : 'restore'
devSay(restore ? 'device restored — it can sign in again' : 'device revoked — its sessions are dead', 'ok')
} catch (e) {
devSay('failed: ' + e.message, 'err')
} finally {
btn.disabled = false
}
})
})
// ── name + bio counters ──────────────────────────────────────────────────────────────────────────
//
// The form itself is a plain POST that works with scripting off; these counters are enhancement only.
// atproto caps displayName at 64 GRAPHEMES and description at 256 — counted the same way the composer
// does, because .length over-counts emoji and would warn about a legal value.
const pfSeg = (typeof Intl !== 'undefined' && Intl.Segmenter) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null
const pfCount = (s) => pfSeg ? [...pfSeg.segment(s)].length : [...String(s)].length
function wireCounter (fieldId, ccId, limit) {
const el = document.getElementById(fieldId)
const cc = document.getElementById(ccId)
if (!el || !cc) return
const upd = () => {
const left = limit - pfCount(el.value)
cc.textContent = left > Math.min(20, limit / 4) ? '' : left + ' left'
cc.classList.toggle('over', left < 0)
}
el.addEventListener('input', upd)
upd()
return () => limit - pfCount(el.value) >= 0
}
const nameOk = wireCounter('pf-name', 'pf-name-cc', 64)
const bioOk = wireCounter('pf-bio', 'pf-bio-cc', 256)
const proform = document.querySelector('.proform')
if (proform) {
// Block a submit that the server would reject anyway, so the user sees it before a round-trip.
proform.addEventListener('submit', (e) => {
if ((nameOk && !nameOk()) || (bioOk && !bioOk())) e.preventDefault()
})
}
// ── banner ───────────────────────────────────────────────────────────────────────────────────────
// Same flow as the avatar above, against /api/banner. Kept as its own handler and its own route rather
// than a shared one with a flag, so a bug in either cannot write the other's profile field.
const bfile = document.getElementById('banner-file')
const bstatus = document.getElementById('banner-status')
function bsay (msg, cls) {
if (!bstatus) return
bstatus.textContent = msg
bstatus.className = cls || ''
}
if (bfile) {
bfile.addEventListener('change', async () => {
const f = bfile.files && bfile.files[0]
if (!f) return
if (f.size > MAX) { bsay(`that image is ${Math.round(f.size / 1024)} KB — the limit is 1024 KB`, 'err'); bfile.value = ''; return }
bsay('uploading…')
try {
const res = await fetch('/api/banner', {
method: 'POST',
headers: { 'content-type': f.type || 'application/octet-stream' },
body: f
})
const body = await res.json().catch(() => ({}))
if (!res.ok) { bsay(body.error || `upload failed (${res.status})`, 'err'); return }
bsay('banner updated — your hpp signed the profile record', 'ok')
// Content-addressed, so the new cid is a different URL — nothing to cache-bust.
if (body.cid) {
const url = `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(body.did)}&cid=${encodeURIComponent(body.cid)}`
let holder = document.querySelector('.profbanner.small')
if (!holder) {
holder = document.createElement('div')
holder.className = 'profbanner small'
const anchor = bfile.closest('section')
if (anchor) anchor.insertBefore(holder, anchor.querySelector('p:has(.imgpick)') || anchor.lastElementChild)
}
holder.innerHTML = ''
const n = document.createElement('img')
n.src = url; n.alt = 'current banner'
holder.appendChild(n)
}
} catch (e) {
bsay((e && e.message) || 'upload failed', 'err')
}
})
}