OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / notifications.js

127 lines · javascript ref:
// notifications.js — "did anyone reply to me", answered from what THIS NODE already knows.
//
// ⛔ WHAT THIS REPLACES. /notifications rendered a shell and asked the browser for a bsky APP
// PASSWORD, then fetched bsky's notification API client-side. Three things wrong with that: it needs
// a second credential for an account you already control here; it shows bsky's view rather than this
// node's; and it is blind to ActivityPub entirely. hyper-social #45 called it "an empty shell" and
// put "the 'did anyone reply to me' loop does not work" among the five gaps that decide whether
// favela is a product.
//
// The data was already here. On the atproto side the inbound lens folds replies, quotes and likes
// that touch our content into the appview (spacedust live, Constellation backfill), so `getReplies`
// answers "who replied to this" from a local index with no credential and no remote call. On the
// ActivityPub side ap-bridge now keeps what the fediverse sends (0.0.6) and exposes it over its
// control socket. This merges the two into one list.
//
// ⛔ ONE SURFACE, BOTH NETWORKS, ATTRIBUTED. #41's rule: inbound Create/Follow/Like/Announce from the
// fediverse must land in the SAME place as atproto interactions "or we ship two inboxes and a
// confused user". They are merged here — and each item keeps its `network`, because a reply from
// Mastodon and a reply from bsky are not interchangeable and a UI that pretends otherwise is lying
// about where a conversation is happening.

// A notification is normalised to one shape regardless of which network produced it, so rendering
// does not branch per source.
//   kind    reply | like | boost | mention
//   network atproto | activitypub
//   who     display string for the author (handle, or a short did/actor)
//   when    ISO timestamp for ordering and display
//   text    what they said, when the kind carries text
//   subject what of OURS it touches (uri/objectId), for linking back
//   url     where to read it in full, when the source gives one

const AP_KIND = { Create: 'reply', Update: 'reply', Like: 'like', Announce: 'boost' }

// A remote actor URI is long and mostly noise. `https://prsm.space/users/fleeky` -> `@fleeky@prsm.space`
// — the form a fediverse user recognises as their own name.
export function actorHandle (actorUri) {
  try {
    const u = new URL(String(actorUri))
    const name = u.pathname.split('/').filter(Boolean).pop()
    return name ? '@' + name + '@' + u.host : u.host
  } catch {
    return String(actorUri || '')
  }
}

// ⛔ HTML FROM A REMOTE SERVER IS NOT MARKUP WE TRUST. ap-bridge stores `content` verbatim, which is
// correct — it is not the store's job to decide how it renders. Here it becomes TEXT: tags stripped,
// entities left for the renderer to escape. A notification line is not a place to reproduce someone
// else's markup, and treating it as such is how a mention becomes a script tag.
export function htmlToText (html) {
  return String(html || '')
    .replace(/<br\s*\/?>/gi, ' ')
    .replace(/<\/p>/gi, ' ')
    .replace(/<[^>]*>/g, '')
    .replace(/\s+/g, ' ')
    .trim()
}

/** One ap-bridge inbound item -> a notification, or null if it is not one we can present. */
export function fromApItem (item) {
  if (!item || !item.type) return null
  const kind = AP_KIND[item.type]
  if (!kind) return null
  return {
    kind,
    network: 'activitypub',
    who: actorHandle(item.actor),
    when: item.published || (item.receivedAt ? new Date(item.receivedAt).toISOString() : null),
    text: kind === 'reply' ? htmlToText(item.content) : '',
    // For a reply this is OUR post it answers; for a like/boost it is OUR post they acted on.
    subject: item.inReplyTo || item.objectId || null,
    url: item.url || item.objectId || null,
    id: item.id || null
  }
}

/** One indexed atproto reply -> a notification. `post` is an appview-hydrated reply. */
export function fromReply (post, subjectUri) {
  if (!post) return null
  const rec = post.record || {}
  return {
    kind: 'reply',
    network: 'atproto',
    who: post.handle || post.author?.handle || shortDid(post.did),
    when: rec.createdAt || post.createdAt || null,
    text: typeof rec.text === 'string' ? rec.text : '',
    subject: subjectUri || null,
    url: post.uri || null,
    id: post.uri || null
  }
}

function shortDid (did) {
  const d = String(did || '')
  const id = d.startsWith('did:plc:') ? d.slice('did:plc:'.length) : d
  return id.length > 14 ? id.slice(0, 8) + '…' + id.slice(-4) : id
}

/**
 * Merge both networks into one list, newest first.
 *
 * ⛔ YOUR OWN ACTIVITY IS NOT A NOTIFICATION. Replies you wrote to your own thread are indexed
 * exactly like anyone else's, so without this filter the list fills with your own voice — which is
 * both useless and the fastest way to make a user stop opening the page.
 *
 * Deduped by id: the same reply can arrive from more than one source (a fediverse reply that also
 * federates back, a record seen via both spacedust paths).
 */
export function mergeNotifications ({ atproto = [], activitypub = [], viewerDid = null, viewerActors = [], limit = 50 } = {}) {
  const mine = new Set([viewerDid, ...viewerActors].filter(Boolean))
  const seen = new Set()
  const out = []

  for (const n of [...atproto, ...activitypub]) {
    if (!n) continue
    if (n.authorDid && mine.has(n.authorDid)) continue
    if (n.id && seen.has(n.id)) continue
    if (n.id) seen.add(n.id)
    out.push(n)
  }

  // Undated items sort last rather than being dropped: "we do not know when" is not "it did not
  // happen", and a notification with a missing timestamp is still a notification.
  out.sort((a, b) => String(b.when || '').localeCompare(String(a.when || '')))
  return out.slice(0, limit)
}