// 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)
}