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