OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / web / static / notifications.js

184 lines · javascript ref:
// 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()