OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / web / static / settings.js

232 lines · javascript ref:
// 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')
    }
  })
}