// feed.js — the bridge's public face, as a feed.
//
// ⛔ A PRODUCT SURFACE AND A DIAGNOSTIC, DELIBERATELY THE SAME PAGE. `/timeline` showed only what the
// fediverse SENT us, as a flat list of activity types. That answers "did anything arrive" and cannot
// answer the thing this project keeps getting wrong: are interactions handled in BOTH directions? On
// a feed, a like we received and a post we published sit in one stream, so a missing one is VISIBLE
// rather than inferred from a log — which is how every interaction defect so far has been found, and
// always later than it should have been.
//
// ⛔ PUBLIC ONLY, AND THE FILTER IS THE SAFETY PROPERTY. There is no login in front of this page.
// Mastodon delivers DIRECT MESSAGES to an inbox exactly as it delivers replies, through the same
// handler and into the same store, and `normalize()` records `public` at arrival because the
// addressing cannot be recovered afterwards. Anything that cannot POSITIVELY show it was public is
// withheld — including everything stored before that field existed.
//
// ⛔ AND SCOPED TO ONE PEER. One InboundStore serves the whole process while the inbox paths are
// per-user, so a feed that did not filter by `recipient` would publish one peer's mail on another's
// page. Items with no recipient belong to nobody rather than everybody.
//
// PURE. No store, no network, no clock — the merge, the verbs, the withholding and the ordering are
// testable on their own, which is the only way a safety filter should ever be checked.
import { showableStored } from './inbound-store.js'
// AS2 activity type → what a reader is actually being told. A `Create` is a reply when it answers
// something and a mention otherwise — the distinction `inReplyTo` was kept for.
const VERB = {
Like: { kind: 'like', verb: 'liked your post' },
Announce: { kind: 'boost', verb: 'boosted your post' },
Follow: { kind: 'follow', verb: 'followed you' }
}
/**
* A remote actor URI as the handle its owner would recognise.
*
* `https://mastodon.social/users/alice` -> `@alice@mastodon.social`. A feed showing raw URLs does not
* read as a feed, and this needs no fetch: the URI already carries both halves.
*
* ⚠ Anything unparseable is returned AS-IS rather than guessed at. A wrong handle attributes
* somebody's words to somebody else, which is worse than an ugly one.
*/
export function handleOf (actorUri) {
const s = String(actorUri == null ? '' : actorUri)
try {
const u = new URL(s)
const name = u.pathname.split('/').filter(Boolean).pop()
if (!name) return u.host
return '@' + name.replace(/^@/, '') + '@' + u.host
} catch {
return s
}
}
// Somebody else's HTML is not markup we render. Tags are stripped here and the result is escaped by
// the renderer: stripping alone leaves `<script>` readable as text, escaping alone lets a
// remote server choose this page's layout.
const textOf = (html) => String(html == null ? '' : html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
/**
* An at:// subject as a place a reader can actually go.
*
* ⛔ NOT AN IDENTITY, A LOCATION. Bluesky content has no ActivityPub URI and we will not mint one on a
* host we do not control (hyper-social #71 §9). But `at://<did>/app.bsky.feed.post/<rkey>` names a post
* that exists somewhere a person can read it, and saying so is honest where inventing an id is not.
*
* ⚠ Returns null rather than guessing. A link that goes nowhere is worse than no link.
*/
export function bskyUrl (atUri) {
if (typeof atUri !== 'string' || !atUri.startsWith('at://')) return null
const parts = atUri.slice('at://'.length).split('/')
if (parts.length !== 3) return null
const [did, collection, rkey] = parts
if (!did.startsWith('did:') || collection !== 'app.bsky.feed.post' || !rkey) return null
return 'https://bsky.app/profile/' + did + '/post/' + rkey
}
/**
* Whose item is this, when nothing on it says so?
*
* ⛔ FROM THE OBJECT URI, WHICH IS STILL THERE. `/ap/objects/<rkey>` is the primary's flat path and
* `/ap/users/<name>/objects/<rkey>` is that user's — the legacy shorthand and the scoped form. Items
* stored before `recipient` existed belong to nobody, which is right when nothing can say whose they
* were; this reads addressing that was never lost rather than inventing addressing that was.
*
* ⚠ OUR HOST ONLY. Another server's path structure is not ours to interpret, and a guess there would
* put a stranger's interaction on somebody's page.
*/
export function recipientFromObject (uri, host) {
if (!host || typeof uri !== 'string') return null
let u
try { u = new URL(uri) } catch { return null }
if (u.host !== host) return null
const p = u.pathname.split('/').filter(Boolean)
// /ap/users/<name>/...
if (p[0] === 'ap' && p[1] === 'users' && p[2]) return p[2]
// /ap/objects/<rkey> — the primary's, by the same rule that made the bare form its shorthand.
if (p[0] === 'ap' && p[1] === 'objects') return '__primary__'
return null
}
const timeOf = (i) => i.published || (i.receivedAt ? new Date(Number(i.receivedAt)).toISOString() : '')
// ⛔ BOUNDED, AND ONLY WHAT CAN BE SHOWN. The list comes from a remote server on the inbound side, so
// an unbounded one lets a stranger decide how tall our page is. An entry with no url names nothing and
// is dropped rather than rendered as a broken box.
//
// ⚠ `name` is the ALT TEXT and it travels. A picture with no description is unreadable to some
// readers, and dropping it here is the kind of loss nobody notices because the page still looks fine.
const MAX_ATTACHMENTS = 8
const pickAttachments = (list) => (Array.isArray(list) ? list : [])
.filter((a) => a && typeof a.url === 'string' && a.url)
.slice(0, MAX_ATTACHMENTS)
.map((a) => ({ url: a.url, mediaType: a.mediaType || null, name: a.name || null }))
/**
* One feed, newest first, from this peer's own public notes and the public activity sent to them.
*
* @param {object} o
* self { handle, name, avatar } — how to attribute our own rows
* notes AS2 Notes from the peer's own records (already public by construction: the outbox
* serves them unauthenticated)
* inbound normalized inbound items
* own OUR interactions, from RecordSource.listInteractions()
* recipient which peer's page this is; null means the primary's flat paths
*/
export function buildFeed ({ self = {}, notes = [], inbound = [], own = [], unprojectable = [], recipient = null, host = null, isPrimary = true } = {}) {
const rows = []
for (const n of notes || []) {
if (!n) continue
rows.push({
id: n.id || null,
kind: 'post',
verb: null,
author: { handle: self.handle || null, name: self.name || null, url: n.attributedTo || null, avatar: self.avatar || null },
at: n.published || '',
text: textOf(n.content),
url: n.id || null,
subject: null,
attachments: pickAttachments(n.attachment),
source: 'own'
})
}
// ⛔ THE OUTBOUND HALF, AND THE REASON THIS PAGE IS WORTH OPENING. Without it the feed shows our
// posts and other people's reactions to them — which proves INBOUND handling and says nothing about
// whether a like we sent ever left. Both directions in one stream makes a broken one visible instead
// of inferred from a delivery log.
//
// ⚠ FIRST PERSON, deliberately. "liked your post" and "you liked a post" are different events and a
// reader must never have to work out which way round one is.
for (const o of own || []) {
if (!o || !o.activity) continue
const kind = o.kind === 'announce' ? 'boost' : o.kind === 'like' ? 'like' : null
// Same rule as inbound: a kind we have no words for is dropped rather than shown as the nearest
// familiar one.
if (!kind) continue
const object = typeof o.activity.object === 'string' ? o.activity.object : null
rows.push({
id: o.key || o.activity.id || null,
kind,
verb: kind === 'like' ? 'you liked a post' : 'you boosted a post',
author: { handle: self.handle || null, name: self.name || null, url: null, avatar: self.avatar || null },
// ⚠ projectLike carries no `published` (projectAnnounce does), so the time comes from the
// RECORD via listInteractions. Undated still shows, sorted last.
at: o.at || o.activity.published || '',
text: '',
url: object,
subject: object,
attachments: [],
source: 'own'
})
}
// ⛔ OUR ACTS THAT CANNOT CROSS. Measured on the live bridge: every unaddressable interaction is a
// like of a Bluesky post, which has no ActivityPub identity by protocol. The projection stays
// refused — nothing new goes on the wire — but the act is ours and this page is ours, so it is shown
// and linked to where the post actually lives. Silently omitting it is how "6 could not be
// addressed" stayed an abstraction for as long as it did.
for (const u of unprojectable || []) {
if (!u) continue
const kind = u.kind === 'announce' ? 'boost' : u.kind === 'like' ? 'like' : null
if (!kind) continue
rows.push({
id: 'unprojectable:' + (u.kind || '') + ':' + (u.rkey || ''),
kind,
verb: kind === 'like' ? 'you liked a post' : 'you boosted a post',
author: { handle: self.handle || null, name: self.name || null, url: null, avatar: self.avatar || null },
at: u.at || '',
text: '',
url: bskyUrl(u.subject),
subject: bskyUrl(u.subject),
attachments: [],
network: 'bsky',
source: 'own'
})
}
for (const i of inbound || []) {
if (!i) continue
// ⛔ FAILS CLOSED, TWICE. `public !== true` catches both a DM and an item stored before the field
// existed; the recipient check catches another peer's mail. Neither is a formality: each is a
// live class of item in the one store this reads.
// ⛔ FAILS CLOSED FOR CONTENT. A Create with no addressing is a DM until proven otherwise, and a
// DM arrives at this inbox through the same handler. `showableStored` widens this for a Like or a
// Follow only, where a stored `false` is the old rule mistaking "no audience" for "not public".
if (!showableStored(i)) continue
if (recipient) {
// An item that says whose it is is believed. One that does not is recovered from the object
// path, and dropped when that cannot answer either.
let owner = i.recipient || null
if (!owner) {
const derived = recipientFromObject(i.inReplyTo || i.objectId, host)
owner = derived === '__primary__' ? (isPrimary ? recipient : null) : derived
}
if (owner !== recipient) continue
}
let kind, verb
if (i.type === 'Create' || i.type === 'Update') {
kind = i.inReplyTo ? 'reply' : 'mention'
verb = i.inReplyTo ? null : 'mentioned you'
} else {
const v = VERB[i.type]
// An activity type we have no words for is DROPPED. Rendering it as the nearest familiar verb
// tells the reader something that did not happen.
if (!v) continue
kind = v.kind
verb = v.verb
}
rows.push({
id: i.id || null,
kind,
verb,
author: { handle: handleOf(i.actor), name: null, url: i.actor || null, avatar: null },
at: timeOf(i),
text: textOf(i.content),
url: i.url || i.objectId || null,
// What of OURS it acted on, so a reader can follow it back.
subject: i.inReplyTo || (kind === 'follow' ? null : i.objectId) || null,
attachments: pickAttachments(i.attachment),
source: 'fediverse'
})
}
// Undated rows sort last rather than being dropped: "we do not know when" is not "it did not
// happen", and an item with no timestamp is still something that arrived.
rows.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))
return rows
}
export default { buildFeed, threadFeed, handleOf, bskyUrl }
/**
* A flat feed, grouped into posts with what happened to them attached.
*
* ⛔ NOTHING IS DROPPED BY GROUPING. An interaction whose subject is not a post on this page — older
* than the window, or never ours — stays TOP-LEVEL. A grouping that silently swallowed it would make
* this page a worse record than the flat list it replaces, and "it was there, just not shown" is the
* shape of every interaction defect this project has had.
*
* ⚠ COUNTS ARE THE LISTS' OWN LENGTHS. A count maintained separately from the thing it counts is a
* disagreement waiting to happen, and the icons are the first place anyone would believe it.
*/
export function threadFeed (rows) {
const posts = []
const byUrl = new Map()
for (const r of rows || []) {
if (r.kind !== 'post') continue
const node = { ...r, replies: [], likes: [], boosts: [], counts: { replies: 0, likes: 0, boosts: 0 } }
posts.push(node)
if (r.url) byUrl.set(r.url, node)
}
const out = [...posts]
for (const r of rows || []) {
if (r.kind === 'post') continue
const parent = r.subject ? byUrl.get(r.subject) : null
if (!parent) { out.push(r); continue }
if (r.kind === 'reply') parent.replies.push(r)
else if (r.kind === 'like') parent.likes.push(r)
else if (r.kind === 'boost') parent.boosts.push(r)
else { out.push(r); continue }
}
for (const p of posts) {
p.counts = { replies: p.replies.length, likes: p.likes.length, boosts: p.boosts.length }
}
out.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))
return out
}