// "Did anyone reply to me" — from this node's own knowledge, both networks, no second credential.
//
// The page this feeds used to be a shell that asked the browser for a bsky APP PASSWORD and fetched
// bsky's notification API client-side: a second credential for an account already controlled here, a
// view that was bsky's rather than ours, and total blindness to ActivityPub. hyper-social #45 listed
// it among the five gaps that decide whether favela is a product.
import { fromApItem, fromReply, mergeNotifications, actorHandle, htmlToText } from '../lib/notifications.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
const eq = (a, b, m) => ok(a === b, `${m} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`)
// A real item, copied from what ap-bridge actually stored when prsm.space replied on 2026-08-18.
const AP_REPLY = {
id: 'https://prsm.space/users/fleeky/statuses/117118370829261804/activity',
type: 'Create',
actor: 'https://prsm.space/users/fleeky',
objectId: 'https://prsm.space/users/fleeky/statuses/117118370829261804',
inReplyTo: 'https://ap.7rnx.net/ap/objects/3mtewvmoqec22',
content: '<p><span class="h-card"><a href="https://ap.7rnx.net/">@<span>p2p</span></a></span> this computer needs a can of air</p>',
url: 'https://prsm.space/@fleeky/117118370829261804',
published: '2026-08-18T20:21:55Z',
receivedAt: 1787084518154
}
// — actor names —
eq(actorHandle('https://prsm.space/users/fleeky'), '@fleeky@prsm.space', 'a fediverse actor reads as the handle its owner knows')
eq(actorHandle('nonsense'), 'nonsense', 'an unparseable actor is passed through, not crashed on')
// — remote HTML is TEXT here —
// ⛔ ap-bridge stores content verbatim, which is right: it is not the store's job to decide how it
// renders. A notification line is not the place to reproduce someone else's markup.
eq(htmlToText('<p>hello<br />there</p>'), 'hello there', 'tags become spaces')
ok(!htmlToText(AP_REPLY.content).includes('<'), 'no markup survives into a notification line')
eq(htmlToText(AP_REPLY.content), '@p2p this computer needs a can of air', 'the words survive, and the mention reads as @p2p rather than being split by the h-card markup')
// — the AP reply —
{
const n = fromApItem(AP_REPLY)
eq(n.kind, 'reply', 'Create{Note} is a reply')
eq(n.network, 'activitypub', 'and it is labelled as fediverse')
eq(n.who, '@fleeky@prsm.space')
eq(n.when, '2026-08-18T20:21:55Z', 'the author time, not when we happened to receive it')
eq(n.subject, 'https://ap.7rnx.net/ap/objects/3mtewvmoqec22', 'which of OUR posts it answers')
ok(n.text.includes('can of air'), 'and what they said')
}
// Likes and boosts carry no text and must not pretend to.
eq(fromApItem({ type: 'Like', actor: 'https://a.net/users/b', objectId: 'https://ap.7rnx.net/ap/objects/x' }).kind, 'like', 'Like')
eq(fromApItem({ type: 'Announce', actor: 'https://a.net/users/b', objectId: 'https://ap.7rnx.net/ap/objects/x' }).kind, 'boost', 'Announce')
eq(fromApItem({ type: 'Like', actor: 'https://a.net/users/b' }).text, '', 'a like has no text')
eq(fromApItem({ type: 'Follow', actor: 'https://a.net/users/b' }), null, 'a Follow is not presented here (yet) rather than mislabelled')
eq(fromApItem(null), null, 'nothing in, nothing out')
// — the atproto reply —
{
const n = fromReply({ did: 'did:plc:alice', handle: 'alice.example', uri: 'at://did:plc:alice/app.bsky.feed.post/r1', record: { text: 'nice', createdAt: '2026-08-18T10:00:00Z' } }, 'at://did:plc:wj62/app.bsky.feed.post/p1')
eq(n.kind, 'reply')
eq(n.network, 'atproto', 'labelled as atproto — a reply from Mastodon and one from bsky are not interchangeable')
eq(n.who, 'alice.example')
eq(n.subject, 'at://did:plc:wj62/app.bsky.feed.post/p1')
}
// A handle-less author falls back to a SHORT did, never 32 raw characters.
{
const n = fromReply({ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', uri: 'at://x/y/z', record: { text: 'hi' } }, 'at://s')
eq(n.who, 'wj62ebbu…ogzo', 'readable, not decoration')
}
// — the merge —
{
const merged = mergeNotifications({
atproto: [fromReply({ did: 'did:plc:alice', handle: 'alice.example', uri: 'at://a/1', record: { text: 'older', createdAt: '2026-08-18T09:00:00Z' } }, 'at://s')],
activitypub: [fromApItem(AP_REPLY)]
})
ok(merged.length === 2, 'both networks in ONE list — two inboxes is how you get a confused user')
eq(merged[0].network, 'activitypub', 'newest first, regardless of source')
eq(merged[1].when, '2026-08-18T09:00:00Z')
}
// ⛔ Your own replies are indexed exactly like anyone else's, so without this the page fills with
// your own voice.
{
const mine = { ...fromReply({ did: 'did:plc:me', handle: 'me', uri: 'at://mine/1', record: { text: 'self', createdAt: '2026-08-18T23:00:00Z' } }, 'at://s'), authorDid: 'did:plc:me' }
const theirs = { ...fromReply({ did: 'did:plc:alice', handle: 'alice', uri: 'at://a/2', record: { text: 'theirs', createdAt: '2026-08-18T22:00:00Z' } }, 'at://s'), authorDid: 'did:plc:alice' }
const merged = mergeNotifications({ atproto: [mine, theirs], viewerDid: 'did:plc:me' })
eq(merged.length, 1, 'your own reply is not a notification')
eq(merged[0].who, 'alice')
}
// The same reply can arrive twice — a fediverse reply that also federates back, or both spacedust
// subject paths hitting one record.
{
const a = fromApItem(AP_REPLY)
const merged = mergeNotifications({ activitypub: [a, { ...a }] })
eq(merged.length, 1, 'deduped by id')
}
// An undated item sorts last rather than vanishing: "we do not know when" is not "it did not happen".
{
const undated = { kind: 'like', network: 'activitypub', who: '@x@y.z', when: null, id: 'u1' }
const dated = fromApItem(AP_REPLY)
const merged = mergeNotifications({ activitypub: [undated, dated] })
eq(merged.length, 2)
eq(merged[1].id, 'u1', 'undated last, still present')
}
// ── the AP inbox reader ─────────────────────────────────────────────────────────────────────────
//
// ⛔ null vs [] IS THE WHOLE CONTRACT. ap-bridge is single-instance, so a favela-web on any other
// host cannot read it. "You have no replies" and "this node cannot see the fediverse" must not
// render alike — the same distinction the AP reach reader already draws.
{
const { createApInbox } = await import('../lib/ap-inbox.js')
const okClient = { call: async () => ({ ok: true, items: [AP_REPLY] }), close: async () => {} }
const got = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => okClient }).list({})
eq(Array.isArray(got) && got.length, 1, 'items when the bridge answers')
const none = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => ({ call: async () => ({ ok: true, items: [] }), close: async () => {} }) }).list({})
eq(Array.isArray(none) && none.length, 0, 'EMPTY when it answered and had nothing')
const noSock = await createApInbox({ sockPath: '/s', exists: () => false, connect: async () => okClient }).list({})
eq(noSock, null, 'NULL when there is no ap-bridge here at all')
const broke = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => { throw new Error('ECONNREFUSED') } }).list({})
eq(broke, null, 'NULL when it is down — never a throw, the atproto half must still render')
const oldVersion = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => ({ call: async () => { throw new Error('unknown method') }, close: async () => {} }) }).list({})
eq(oldVersion, null, 'NULL against an ap-bridge too old to have the verb')
}
console.log(`\nNOTIFICATIONS OK: ${pass} assertions`)