OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / web / static / report.js

81 lines · javascript ref:
// Report a post or account to a moderation service (com.atproto.moderation.createReport).
//
// SAME CREDENTIAL RULE AS NOTIFICATIONS, for the same reason: createReport is user-scoped, so it needs a
// bearer, and favela-web deliberately holds none. The browser sends it, reusing the session notifications
// already established — one login, not two. If there is no session, the control says so and points at
// /notifications rather than silently doing nothing.
//
// Reports go to the PDS's moderation service. They are NOT public records and nothing is written to the
// user's repo; this is a message to a moderator, which is why it needs no signing key.
const PDS = document.querySelector('meta[name="favela-pds"]')?.content || 'https://pds.7rnx.net'
const KEY = 'favela.notif.session'

const REASONS = [
  ['com.atproto.moderation.defs#reasonSpam', 'spam'],
  ['com.atproto.moderation.defs#reasonViolation', 'rule violation'],
  ['com.atproto.moderation.defs#reasonMisleading', 'misleading'],
  ['com.atproto.moderation.defs#reasonSexual', 'unwanted sexual content'],
  ['com.atproto.moderation.defs#reasonRude', 'rude or harassing'],
  ['com.atproto.moderation.defs#reasonOther', 'other']
]

const session = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null') } catch { return null } }

function panel (uri, cid, anchor) {
  const box = document.createElement('form')
  box.className = 'reportbox'
  const s = session()
  if (!s || !s.accessJwt) {
    box.innerHTML = '<p class="empty">Reporting needs a signed-in session. <a href="/notifications">sign in →</a></p>'
    return box
  }
  const sel = document.createElement('select')
  sel.setAttribute('aria-label', 'reason')
  for (const [v, label] of REASONS) {
    const o = document.createElement('option'); o.value = v; o.textContent = label; sel.appendChild(o)
  }
  const note = document.createElement('input')
  note.type = 'text'; note.placeholder = 'anything the moderator should know (optional)'
  note.setAttribute('aria-label', 'details')
  const send = document.createElement('button')
  send.type = 'submit'; send.textContent = 'send report'
  const status = document.createElement('span')
  status.className = 'empty'; status.setAttribute('aria-live', 'polite')
  box.append(sel, note, send, status)

  box.addEventListener('submit', async (e) => {
    e.preventDefault()
    send.disabled = true
    status.textContent = 'sending…'
    try {
      // A post is reported as a strongRef (uri AND cid); an account as a repoRef with just the did.
      const subject = cid
        ? { $type: 'com.atproto.repo.strongRef', uri, cid }
        : { $type: 'com.atproto.admin.defs#repoRef', did: uri }
      const res = await fetch(`${PDS.replace(/\/$/, '')}/xrpc/com.atproto.moderation.createReport`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', authorization: `Bearer ${s.accessJwt}` },
        body: JSON.stringify({ reasonType: sel.value, reason: note.value || undefined, subject })
      })
      const body = await res.json().catch(() => null)
      if (!res.ok) throw new Error((body && (body.message || body.error)) || `HTTP ${res.status}`)
      status.textContent = 'reported — thank you'
      sel.disabled = note.disabled = true
    } catch (err) {
      status.textContent = (err && err.message) || 'report failed'
      send.disabled = false
    }
  })
  return box
}

document.addEventListener('click', (e) => {
  const btn = e.target.closest && e.target.closest('.reportbtn')
  if (!btn) return
  e.preventDefault()
  e.stopPropagation() // never trigger the post's whole-entry navigation
  const existing = btn.parentElement.querySelector('.reportbox')
  if (existing) { existing.remove(); return } // a second click closes it
  btn.parentElement.appendChild(panel(btn.dataset.uri, btn.dataset.cid || null, btn))
})