// Notifications — fetched BY THE BROWSER, never by the server.
//
// WHY IT LIVES HERE AND NOT ON THE SERVER. hpp's get-service-auth has two gates, and the second is
// `sessions.verifyAccess(token)` — a bearer from a real login. Its comment says why in as many words:
// without it "an allowlisted bridge could mint tokens for anyone at any time with nobody present". A
// minted token authenticates AS the DID, signed by the same #atproto key that signs every repo commit,
// so it is a signing oracle and it is right that it needs a present user.
//
// That leaves the question of where the bearer lives. Putting it on favela-web would mean the server
// holds a credential for you, which is exactly what "favela never holds anything of yours" is supposed
// to rule out. So the BROWSER holds it — the same place that already holds your delegated sub-key
// secret — and favela-web stays keyless and credential-free. It renders the shell and nothing else;
// every authenticated byte here moves between your browser and the PDS.
//
// ⚠ THE PASSWORD IS NEVER STORED. It is used once, in memory, to mint a session, and then dropped. Only
// the JWTs persist, and they expire. An app password is also revocable independently of your account
// password, which is the point of app passwords.
//
// This is the one page in favela that requires JavaScript. Notifications are inherently authenticated
// and per-viewer; there is no meaningful server-rendered version when the server deliberately holds no
// credential. Everything else still works with scripting off.
const PDS = document.querySelector('meta[name="favela-pds"]')?.content || 'https://pds.7rnx.net'
const KEY = 'favela.notif.session'
const $ = (id) => document.getElementById(id)
const out = $('notif-list')
const status = $('notif-status')
const form = $('notif-login')
function say (msg, cls) { if (status) { status.textContent = msg; status.className = cls || '' } }
function loadSession () {
try { return JSON.parse(localStorage.getItem(KEY) || 'null') } catch { return null }
}
function saveSession (s) {
try { localStorage.setItem(KEY, JSON.stringify(s)) } catch { /* private mode — session lasts this page */ }
}
function clearSession () {
try { localStorage.removeItem(KEY) } catch {}
}
async function xrpc (nsid, { method = 'GET', body = null, bearer = null, params = null } = {}) {
const url = new URL(`${PDS.replace(/\/$/, '')}/xrpc/${nsid}`)
if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v))
const headers = {}
if (body) headers['content-type'] = 'application/json'
if (bearer) headers.authorization = `Bearer ${bearer}`
const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined })
const text = await res.text()
let json = null
try { json = text ? JSON.parse(text) : null } catch { /* non-JSON error body */ }
if (!res.ok) throw Object.assign(new Error((json && (json.message || json.error)) || `HTTP ${res.status}`), { status: res.status })
return json
}
// A refresh token is used with the REFRESH token as bearer, not the access one — a detail that silently
// fails as a 401 loop if you get it wrong.
async function refresh (session) {
const r = await xrpc('com.atproto.server.refreshSession', { method: 'POST', bearer: session.refreshJwt })
const next = { ...session, accessJwt: r.accessJwt, refreshJwt: r.refreshJwt }
saveSession(next)
return next
}
const REASON = {
like: 'liked your post', repost: 'reposted your post', follow: 'followed you',
mention: 'mentioned you', reply: 'replied to you', quote: 'quoted your post',
'starterpack-joined': 'joined via your starter pack'
}
function rowFor (n) {
const el = document.createElement('article')
el.className = 'notif' + (n.isRead ? '' : ' unread')
const who = (n.author && (n.author.displayName || n.author.handle)) || (n.author && n.author.did) || 'someone'
const av = document.createElement('span')
if (n.author && typeof n.author.avatar === 'string' && /^https:\/\//i.test(n.author.avatar)) {
const img = document.createElement('img')
img.className = 'pfp'; img.src = n.author.avatar; img.alt = ''; img.width = 36; img.height = 36
img.referrerPolicy = 'no-referrer'
av.replaceWith(img); el.appendChild(img)
} else {
av.className = 'pfp none'; av.setAttribute('aria-hidden', 'true'); el.appendChild(av)
}
const txt = document.createElement('div')
txt.className = 'ntext'
const strong = document.createElement('strong')
strong.textContent = who
txt.appendChild(strong)
// textContent throughout: these are remote strings and must never be parsed as markup.
txt.appendChild(document.createTextNode(' ' + (REASON[n.reason] || n.reason)))
if (n.record && typeof n.record.text === 'string' && n.record.text) {
const q = document.createElement('div')
q.className = 'nquote'
q.textContent = n.record.text.slice(0, 180)
txt.appendChild(q)
}
const meta = document.createElement('div')
meta.className = 'meta'
if (n.indexedAt) {
const t = document.createElement('time')
t.dateTime = n.indexedAt
t.textContent = new Date(n.indexedAt).toLocaleString()
meta.appendChild(t)
}
// Link to OUR view of the subject where we can address it — the uri identifies the record, and favela
// renders it if this node holds it.
const subject = n.reasonSubject || n.uri
const m = /^at:\/\/(did:[^/]+)\/[^/]+\/(.+)$/.exec(String(subject || ''))
if (m) {
const a = document.createElement('a')
a.href = `/post/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`
a.textContent = ' open here'
meta.appendChild(a)
}
txt.appendChild(meta)
el.appendChild(txt)
return el
}
async function load (session) {
say('loading…')
let s = session
let body
try {
body = await xrpc('app.bsky.notification.listNotifications', { bearer: s.accessJwt, params: { limit: 40 } })
} catch (e) {
if (e.status !== 401) throw e
// One refresh attempt, then give up rather than loop.
s = await refresh(s)
body = await xrpc('app.bsky.notification.listNotifications', { bearer: s.accessJwt, params: { limit: 40 } })
}
const list = (body && body.notifications) || []
out.innerHTML = ''
if (!list.length) { say('nothing new', ''); return }
for (const n of list) out.appendChild(rowFor(n))
const unread = list.filter((n) => !n.isRead).length
say(`${list.length} notification${list.length === 1 ? '' : 's'}${unread ? ` · ${unread} unread` : ''}`, 'ok')
}
async function boot () {
const s = loadSession()
if (!s || !s.accessJwt) { if (form) form.hidden = false; say('sign in to load notifications'); return }
if (form) form.hidden = true
try { await load(s) } catch (e) {
// A dead session is not an error to shout about — offer the form again.
clearSession()
if (form) form.hidden = false
say((e && e.message) || 'could not load notifications', 'err')
}
}
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault()
const identifier = $('notif-handle').value.trim()
const password = $('notif-pass').value
if (!identifier || !password) { say('handle and app password required', 'err'); return }
say('signing in…')
try {
const r = await xrpc('com.atproto.server.createSession', { method: 'POST', body: { identifier, password } })
// Only the JWTs are kept. The password is not stored, not logged, and goes out of scope here.
saveSession({ did: r.did, handle: r.handle, accessJwt: r.accessJwt, refreshJwt: r.refreshJwt })
$('notif-pass').value = ''
form.hidden = true
await load(loadSession())
} catch (err) {
say((err && err.message) || 'sign-in failed', 'err')
}
})
}
const signout = $('notif-signout')
if (signout) {
signout.addEventListener('click', () => {
clearSession()
out.innerHTML = ''
if (form) form.hidden = false
say('signed out of notifications on this browser')
})
}
boot()