OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / web / static / actions.js

152 lines · javascript ref:
// Responsive like / repost / follow — progressive enhancement over the plain forms.
//
// THE PROBLEM. Every one of these is a real <form method="POST"> that redirects back to `return`. That is
// what makes them work with scripting off, and it stays the fallback — but with JS on it means a FULL PAGE
// RELOAD to toggle a heart: the timeline is re-fetched and re-rendered, scroll position is lost, and on a
// slow P2P read the whole page stalls behind one click. The affordance felt broken even though it worked.
//
// The server already speaks JSON on these routes (`content-type: application/json` -> {ok, liked,
// likeCount} / {ok, reposted, repostCount} / {ok, following}) instead of redirecting. Nothing server-side
// was needed; only this.
//
// OPTIMISTIC, THEN RECONCILED. The button flips immediately — a P2P write can take a moment and a control
// that does nothing for 400ms reads as broken. If the request fails we put the ORIGINAL state back and say
// so, rather than leaving a lie on screen. The server's returned count is authoritative and overwrites the
// optimistic guess, so a concurrent like from another device self-corrects on the next click.
//
// Delegated on document, so posts swapped in by the live refresh keep working with nothing to re-bind.
import { toast } from './toast.js'

// Naming the action in the message matters: "failed" on its own does not tell you WHICH of three
// controls in a row it was, and the button has already reverted by the time you read it.
const LABEL = { '/api/like': 'like', '/api/repost': 'repost', '/api/follow': 'follow' }

const ROUTES = {
  '/api/like': { btn: '.likebtn', on: 'liked', count: 'likeCount', countEl: '.likes', active: '♥', idle: '♡' },
  '/api/repost': { btn: '.rpbtn', on: 'reposted', count: 'repostCount', countEl: '.rps', active: '⇄', idle: '⇄' },
  '/api/follow': { btn: '.followbtn', on: 'following', count: null, countEl: null, active: 'following ✓', idle: 'follow' }
}

function setState (btn, spec, isOn, count) {
  btn.classList.toggle('on', !!isOn)
  if (spec.active !== spec.idle || spec.count === null) btn.textContent = isOn ? spec.active : spec.idle
  btn.title = spec.count === null ? (isOn ? 'unfollow' : 'follow') : (isOn ? 'undo' : 'do')
  btn.setAttribute('aria-pressed', isOn ? 'true' : 'false')
  if (!spec.countEl) return
  // The count lives OUTSIDE the button, as a sibling in the same form. Absent at zero by design, so it has
  // to be created when a count appears and removed when it returns to zero — otherwise a freshly-liked
  // post shows a heart with no number, or a stale "0" lingers.
  const form = btn.closest('form')
  if (!form) return
  let el = form.querySelector(spec.countEl)
  const n = Number(count)
  if (!Number.isFinite(n) || n <= 0) { if (el) el.remove(); return }
  if (!el) {
    el = document.createElement('span')
    el.className = spec.countEl.slice(1)
    btn.insertAdjacentElement('afterend', el)
  }
  el.textContent = String(n)
}

document.addEventListener('submit', async (e) => {
  const form = e.target
  if (!form || !form.action) return
  const path = new URL(form.action, location.href).pathname
  const spec = ROUTES[path]
  if (!spec) return
  const btn = form.querySelector(spec.btn)
  if (!btn) return

  e.preventDefault()
  const data = new FormData(form)
  const wasOn = btn.classList.contains('on')
  const countEl = spec.countEl ? form.querySelector(spec.countEl) : null
  const wasCount = countEl ? Number(countEl.textContent) || 0 : 0

  // Optimistic flip. A P2P write is not instant and the click must feel like it landed.
  setState(btn, spec, !wasOn, wasOn ? wasCount - 1 : wasCount + 1)
  btn.disabled = true

  try {
    const body = {}
    for (const [k, v] of data.entries()) body[k] = v
    const res = await fetch(path, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body)
    })
    const out = await res.json().catch(() => null)
    if (!res.ok || !out || out.ok !== true) throw new Error((out && out.error) || ('HTTP ' + res.status))
    // The server's answer wins over the optimistic guess.
    setState(btn, spec, out[spec.on], spec.count ? out[spec.count] : null)
  } catch (err) {
    setState(btn, spec, wasOn, wasCount) // put it back — never leave a lie on screen
    btn.classList.add('failed')
    const why = String((err && err.message) || 'failed')
    btn.title = why
    // ...and SAY it. The flash + title alone meant a failed write was invisible on touch (no hover) and
    // to assistive tech, so a revert read as "my tap didn't register" and people tapped again.
    toast(`${LABEL[path] || 'action'} failed — ${why}`)
    setTimeout(() => btn.classList.remove('failed'), 1200)
  } finally {
    btn.disabled = false
  }
})

// ── lightbox ─────────────────────────────────────────────────────────────────────────────────────
// Each image is a real <a> to its own bytes, so with no JS a click opens the full-size picture — already
// better than the inline-only view. With JS we keep the reader on the page instead.
//
// It must be a link regardless: the whole post is a stretched anchor to its thread, so an image with no
// element of its own would open the thread rather than the picture.
let lb = null
function closeLb () {
  if (!lb) return
  lb.remove(); lb = null
  document.removeEventListener('keydown', onKey)
  document.body.style.overflow = ''
}
function onKey (e) { if (e.key === 'Escape') closeLb() }

document.addEventListener('click', (e) => {
  const a = e.target.closest && e.target.closest('a.lb')
  if (!a) return
  // Let the browser do its normal thing for new-tab/window intents — never hijack a modified click.
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
  e.preventDefault()
  e.stopPropagation() // do not also trigger the post's whole-entry navigation

  closeLb()
  lb = document.createElement('div')
  lb.className = 'lbox'
  lb.setAttribute('role', 'dialog')
  lb.setAttribute('aria-modal', 'true')
  const alt = a.getAttribute('data-alt') || ''
  lb.setAttribute('aria-label', alt || 'image')
  const img = document.createElement('img')
  img.src = a.getAttribute('href')
  img.alt = alt
  lb.appendChild(img)
  // ALT TEXT IS SHOWN, not just carried in the attribute. Someone wrote it to describe the image; a
  // viewer is the one place there is room to actually display it.
  if (alt) {
    const cap = document.createElement('p')
    cap.className = 'lbalt'
    cap.textContent = alt
    lb.appendChild(cap)
  }
  const close = document.createElement('button')
  close.className = 'lbclose'
  close.setAttribute('aria-label', 'close')
  close.textContent = '✕'
  lb.appendChild(close)

  // Click anywhere outside the image closes — the expected gesture, and the only one available on touch.
  lb.addEventListener('click', (ev) => { if (ev.target !== img) closeLb() })
  document.body.appendChild(lb)
  document.body.style.overflow = 'hidden' // no scrolling the page behind the overlay
  document.addEventListener('keydown', onKey)
  close.focus()
})