| … | … | @@ -26,6 +26,8 @@ import { Appview, INGEST_VERSION, threadPostList } from '../lib/appview.js' |
| 26 | 26 | import { parseLenses, lensesEnabled } from '../lib/lenses.js' |
| 27 | 27 | import { createLensReader, extractThreadReplies, extractThreadParents } from '../lib/lens-import.js' |
| 28 | 28 | import { frontsDiag } from '../lib/fronts-diag.js' |
| 29 | import { fromApItem, fromReply, mergeNotifications } from '../lib/notifications.js' | |
| 30 | import { createApInbox } from '../lib/ap-inbox.js' | |
| 29 | 31 | import { tidNow } from 'atproto-store/lib/tid.js' // rkey minting for like/follow records |
| 30 | 32 | import { strongRef, parseAtUri } from '../lib/strongref.js' |
| 31 | 33 | import { resolveVerifyKey } from '../lib/plc.js' |
| … | … | @@ -350,6 +352,15 @@ async function apProjected () { |
| 350 | 352 | try { return await apFollowers.projectedRkeys(ROSTER[0]) } catch { return null } |
| 351 | 353 | } |
| 352 | 354 | |
| 355 | // The fediverse half of notifications. Local socket only — ap-bridge is single-instance and holds | |
| 356 | // other people's messages to us, so it is not a public endpoint. Returns null when this node cannot | |
| 357 | // ask, which the page renders as "cannot see them" rather than "none". | |
| 358 | const apInbox = createApInbox({ | |
| 359 | home: process.env.HOME || '', | |
| 360 | exists: (p) => { try { return fs.existsSync(p) } catch { return false } }, | |
| 361 | connect: async (p) => { const { Client } = await import('hyper-svc'); return Client.connectUds(p) } | |
| 362 | }) | |
| 363 | ||
| 353 | 364 | const followerLens = createFollowerLens({ |
| 354 | 365 | constellation: createConstellationClient({ |
| 355 | 366 | doFetch: bareFetch, |
| … | … | @@ -1730,7 +1741,37 @@ const server = http.createServer(async (req, res) => { |
| 1730 | 1741 | // hpp requires a real user bearer to mint service auth (deliberately), and holding one here would |
| 1731 | 1742 | // make favela-web a credential store, which is the thing it is built not to be. |
| 1732 | 1743 | if (path === '/notifications') { |
| 1733 | return html(res, 200, notificationsPage({ viewer, pds: EXTERNAL_URL || 'https://pds.7rnx.net' })) | |
| 1744 | // Answered from what this node already knows: the inbound lens folds replies touching our | |
| 1745 | // content into the appview, and ap-bridge keeps what the fediverse sent. No credential. | |
| 1746 | let items = [] | |
| 1747 | let apAvailable = null | |
| 1748 | if (viewer) { | |
| 1749 | const collected = [] | |
| 1750 | try { | |
| 1751 | // Our own recent posts are the subjects anyone could have replied to. The reply index is | |
| 1752 | // keyed by parent uri, so this is N cheap local range reads, not a network call. | |
| 1753 | const own = await av.getAuthorFeed(viewer.did, { limit: 20, filter: 'posts_no_replies' }) | |
| 1754 | for (const post of (own && own.posts) || []) { | |
| 1755 | if (!post || !post.uri) continue | |
| 1756 | for (const r of await av.getReplies(post.uri, { limit: 20 })) { | |
| 1757 | if (!r || r.did === viewer.did) continue // your own reply is not a notification | |
| 1758 | collected.push({ ...fromReply(r, post.uri), authorDid: r.did }) | |
| 1759 | } | |
| 1760 | } | |
| 1761 | } catch (e) { | |
| 1762 | console.error(JSON.stringify({ msg: 'notifications: atproto side failed', error: e.message })) | |
| 1763 | } | |
| 1764 | ||
| 1765 | const apItems = await apInbox.list({ limit: 50 }) | |
| 1766 | apAvailable = apItems !== null // null means COULD NOT ASK, not "none" | |
| 1767 | items = mergeNotifications({ | |
| 1768 | atproto: collected, | |
| 1769 | activitypub: (apItems || []).map(fromApItem).filter(Boolean), | |
| 1770 | viewerDid: viewer.did, | |
| 1771 | limit: 50 | |
| 1772 | }) | |
| 1773 | } | |
| 1774 | return html(res, 200, notificationsPage({ viewer, items, apAvailable, pds: EXTERNAL_URL || 'https://pds.7rnx.net' })) | |
| 1734 | 1775 | } |
| 1735 | 1776 | |
| 1736 | 1777 | // Bookmarks — the viewer's own saved posts. Nothing shared, nothing federated. |
| … | … | @@ -0,0 +1,55 @@ |
| 1 | // ap-inbox.js — read what the fediverse sent us, from ap-bridge's control socket. | |
| 2 | // | |
| 3 | // ap-bridge 0.0.6 keeps inbound replies, mentions, likes and boosts (before that it verified them, | |
| 4 | // returned 202, and dropped them). They are NOT served over its public HTTP surface, deliberately: | |
| 5 | // an AP inbox holds other people's messages to us, and publishing it would republish their content | |
| 6 | // under our host. So this reads the control socket, which is local and privileged. | |
| 7 | // | |
| 8 | // ⛔ LOCAL ONLY, AND THAT IS THE HONEST LIMIT. ap-bridge is single-instance by construction — its RSA | |
| 9 | // keypair, follower list and delivered-map are per-node state, and ap.7rnx.net is a static vhost to | |
| 10 | // one backend. A favela-web on any other host cannot read this and must SAY so rather than render an | |
| 11 | // empty list. | |
| 12 | // | |
| 13 | // ⛔ null MEANS "COULD NOT ASK". [] means "asked, nothing there". The existing AP reach reader draws | |
| 14 | // exactly this distinction ("so 'not projected' and 'could not ask' never render alike") and the | |
| 15 | // notifications page depends on it: a user with no replies and a user whose node cannot see the | |
| 16 | // fediverse must not be shown the same page. | |
| 17 | ||
| 18 | const DEFAULT_SOCK = '/.config/ap-bridge/svc.sock' | |
| 19 | ||
| 20 | export function createApInbox ({ sockPath = null, connect = null, home = null, exists = null } = {}) { | |
| 21 | const path = sockPath || ((home || '') + DEFAULT_SOCK) | |
| 22 | ||
| 23 | return { | |
| 24 | get path () { return path }, | |
| 25 | ||
| 26 | // Is there an ap-bridge on this machine at all? Checked before connecting so the common case | |
| 27 | // (no AP side here) costs nothing and logs nothing. | |
| 28 | available () { | |
| 29 | if (!exists) return true // no probe injected: let the connect attempt decide | |
| 30 | try { return !!exists(path) } catch { return false } | |
| 31 | }, | |
| 32 | ||
| 33 | /** | |
| 34 | * @returns {Promise<Array|null>} items, or null when we could not ask. | |
| 35 | */ | |
| 36 | async list ({ limit = 50 } = {}) { | |
| 37 | if (!this.available()) return null | |
| 38 | if (typeof connect !== 'function') return null | |
| 39 | let client = null | |
| 40 | try { | |
| 41 | client = await connect(path) | |
| 42 | const res = await client.call('notifications', { limit }) | |
| 43 | const items = res && res.items | |
| 44 | return Array.isArray(items) ? items : null | |
| 45 | } catch { | |
| 46 | // Unreachable, wrong version (no `notifications` verb), or mid-restart. All the same answer: | |
| 47 | // we could not ask. Never throw — the notifications page must render its atproto half | |
| 48 | // regardless of whether the AP side is up. | |
| 49 | return null | |
| 50 | } finally { | |
| 51 | try { await client?.close?.() } catch {} | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
| 55 | } |
| … | … | @@ -0,0 +1,126 @@ |
| 1 | // notifications.js — "did anyone reply to me", answered from what THIS NODE already knows. | |
| 2 | // | |
| 3 | // ⛔ WHAT THIS REPLACES. /notifications rendered a shell and asked the browser for a bsky APP | |
| 4 | // PASSWORD, then fetched bsky's notification API client-side. Three things wrong with that: it needs | |
| 5 | // a second credential for an account you already control here; it shows bsky's view rather than this | |
| 6 | // node's; and it is blind to ActivityPub entirely. hyper-social #45 called it "an empty shell" and | |
| 7 | // put "the 'did anyone reply to me' loop does not work" among the five gaps that decide whether | |
| 8 | // favela is a product. | |
| 9 | // | |
| 10 | // The data was already here. On the atproto side the inbound lens folds replies, quotes and likes | |
| 11 | // that touch our content into the appview (spacedust live, Constellation backfill), so `getReplies` | |
| 12 | // answers "who replied to this" from a local index with no credential and no remote call. On the | |
| 13 | // ActivityPub side ap-bridge now keeps what the fediverse sends (0.0.6) and exposes it over its | |
| 14 | // control socket. This merges the two into one list. | |
| 15 | // | |
| 16 | // ⛔ ONE SURFACE, BOTH NETWORKS, ATTRIBUTED. #41's rule: inbound Create/Follow/Like/Announce from the | |
| 17 | // fediverse must land in the SAME place as atproto interactions "or we ship two inboxes and a | |
| 18 | // confused user". They are merged here — and each item keeps its `network`, because a reply from | |
| 19 | // Mastodon and a reply from bsky are not interchangeable and a UI that pretends otherwise is lying | |
| 20 | // about where a conversation is happening. | |
| 21 | ||
| 22 | // A notification is normalised to one shape regardless of which network produced it, so rendering | |
| 23 | // does not branch per source. | |
| 24 | // kind reply | like | boost | mention | |
| 25 | // network atproto | activitypub | |
| 26 | // who display string for the author (handle, or a short did/actor) | |
| 27 | // when ISO timestamp for ordering and display | |
| 28 | // text what they said, when the kind carries text | |
| 29 | // subject what of OURS it touches (uri/objectId), for linking back | |
| 30 | // url where to read it in full, when the source gives one | |
| 31 | ||
| 32 | const AP_KIND = { Create: 'reply', Update: 'reply', Like: 'like', Announce: 'boost' } | |
| 33 | ||
| 34 | // A remote actor URI is long and mostly noise. `https://prsm.space/users/fleeky` -> `@fleeky@prsm.space` | |
| 35 | // — the form a fediverse user recognises as their own name. | |
| 36 | export function actorHandle (actorUri) { | |
| 37 | try { | |
| 38 | const u = new URL(String(actorUri)) | |
| 39 | const name = u.pathname.split('/').filter(Boolean).pop() | |
| 40 | return name ? '@' + name + '@' + u.host : u.host | |
| 41 | } catch { | |
| 42 | return String(actorUri || '') | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | // ⛔ HTML FROM A REMOTE SERVER IS NOT MARKUP WE TRUST. ap-bridge stores `content` verbatim, which is | |
| 47 | // correct — it is not the store's job to decide how it renders. Here it becomes TEXT: tags stripped, | |
| 48 | // entities left for the renderer to escape. A notification line is not a place to reproduce someone | |
| 49 | // else's markup, and treating it as such is how a mention becomes a script tag. | |
| 50 | export function htmlToText (html) { | |
| 51 | return String(html || '') | |
| 52 | .replace(/<br\s*\/?>/gi, ' ') | |
| 53 | .replace(/<\/p>/gi, ' ') | |
| 54 | .replace(/<[^>]*>/g, '') | |
| 55 | .replace(/\s+/g, ' ') | |
| 56 | .trim() | |
| 57 | } | |
| 58 | ||
| 59 | /** One ap-bridge inbound item -> a notification, or null if it is not one we can present. */ | |
| 60 | export function fromApItem (item) { | |
| 61 | if (!item || !item.type) return null | |
| 62 | const kind = AP_KIND[item.type] | |
| 63 | if (!kind) return null | |
| 64 | return { | |
| 65 | kind, | |
| 66 | network: 'activitypub', | |
| 67 | who: actorHandle(item.actor), | |
| 68 | when: item.published || (item.receivedAt ? new Date(item.receivedAt).toISOString() : null), | |
| 69 | text: kind === 'reply' ? htmlToText(item.content) : '', | |
| 70 | // For a reply this is OUR post it answers; for a like/boost it is OUR post they acted on. | |
| 71 | subject: item.inReplyTo || item.objectId || null, | |
| 72 | url: item.url || item.objectId || null, | |
| 73 | id: item.id || null | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | /** One indexed atproto reply -> a notification. `post` is an appview-hydrated reply. */ | |
| 78 | export function fromReply (post, subjectUri) { | |
| 79 | if (!post) return null | |
| 80 | const rec = post.record || {} | |
| 81 | return { | |
| 82 | kind: 'reply', | |
| 83 | network: 'atproto', | |
| 84 | who: post.handle || post.author?.handle || shortDid(post.did), | |
| 85 | when: rec.createdAt || post.createdAt || null, | |
| 86 | text: typeof rec.text === 'string' ? rec.text : '', | |
| 87 | subject: subjectUri || null, | |
| 88 | url: post.uri || null, | |
| 89 | id: post.uri || null | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | function shortDid (did) { | |
| 94 | const d = String(did || '') | |
| 95 | const id = d.startsWith('did:plc:') ? d.slice('did:plc:'.length) : d | |
| 96 | return id.length > 14 ? id.slice(0, 8) + '…' + id.slice(-4) : id | |
| 97 | } | |
| 98 | ||
| 99 | /** | |
| 100 | * Merge both networks into one list, newest first. | |
| 101 | * | |
| 102 | * ⛔ YOUR OWN ACTIVITY IS NOT A NOTIFICATION. Replies you wrote to your own thread are indexed | |
| 103 | * exactly like anyone else's, so without this filter the list fills with your own voice — which is | |
| 104 | * both useless and the fastest way to make a user stop opening the page. | |
| 105 | * | |
| 106 | * Deduped by id: the same reply can arrive from more than one source (a fediverse reply that also | |
| 107 | * federates back, a record seen via both spacedust paths). | |
| 108 | */ | |
| 109 | export function mergeNotifications ({ atproto = [], activitypub = [], viewerDid = null, viewerActors = [], limit = 50 } = {}) { | |
| 110 | const mine = new Set([viewerDid, ...viewerActors].filter(Boolean)) | |
| 111 | const seen = new Set() | |
| 112 | const out = [] | |
| 113 | ||
| 114 | for (const n of [...atproto, ...activitypub]) { | |
| 115 | if (!n) continue | |
| 116 | if (n.authorDid && mine.has(n.authorDid)) continue | |
| 117 | if (n.id && seen.has(n.id)) continue | |
| 118 | if (n.id) seen.add(n.id) | |
| 119 | out.push(n) | |
| 120 | } | |
| 121 | ||
| 122 | // Undated items sort last rather than being dropped: "we do not know when" is not "it did not | |
| 123 | // happen", and a notification with a missing timestamp is still a notification. | |
| 124 | out.sort((a, b) => String(b.when || '').localeCompare(String(a.when || ''))) | |
| 125 | return out.slice(0, limit) | |
| 126 | } |
| … | … | @@ -1605,8 +1605,43 @@ export function bookmarksPage ({ result = null, viewer = null, missing = 0 } = { |
| 1605 | 1605 | // This is the ONE page that requires JavaScript, and it says so rather than appearing broken with |
| 1606 | 1606 | // scripting off. Notifications are inherently authenticated and per-viewer; there is no meaningful |
| 1607 | 1607 | // server-rendered version when the server deliberately holds nothing. |
| 1608 | export function notificationsPage ({ viewer = null, pds = 'https://pds.7rnx.net' } = {}) { | |
| 1609 | const body = `<div class="viewhdr">Notifications <span class="scopenote">· fetched by your browser, not by this node</span></div> | |
| 1608 | // One notification line. Kind decides the verb, network decides the badge — a reply from Mastodon | |
| 1609 | // and a reply from bsky are not interchangeable, and a UI that renders them identically is lying | |
| 1610 | // about where the conversation is happening. | |
| 1611 | function notifRow (n) { | |
| 1612 | const verb = n.kind === 'reply' ? 'replied' : n.kind === 'like' ? 'liked' : n.kind === 'boost' ? 'boosted' : 'mentioned you' | |
| 1613 | const net = n.network === 'activitypub' ? 'fediverse' : 'bsky' | |
| 1614 | // n.text is already stripped of remote markup by notifications.js; esc() is the second line of | |
| 1615 | // defence, not the first. | |
| 1616 | const said = n.text ? `<div class="ntext">${esc(n.text.slice(0, 280))}</div>` : '' | |
| 1617 | const when = n.when ? `<time datetime="${esc(n.when)}">${esc(relTime(n.when))}</time>` : '<span class="dim">unknown time</span>' | |
| 1618 | const link = n.url ? `<a href="${esc(n.url)}" rel="noopener">open</a>` : '' | |
| 1619 | return `<div class="notif"><div class="nhead"><strong>${esc(n.who || 'someone')}</strong> ${verb} <span class="netbadge">${net}</span> · ${when} ${link}</div>${said}</div>` | |
| 1620 | } | |
| 1621 | ||
| 1622 | // ⛔ THIS PAGE USED TO ASK FOR A SECOND CREDENTIAL. It rendered a shell and a form for a bsky APP | |
| 1623 | // PASSWORD, then fetched bsky's notification API in the browser — for an account this node already | |
| 1624 | // controls, showing bsky's view rather than ours, and blind to ActivityPub entirely (#45: "the 'did | |
| 1625 | // anyone reply to me' loop does not work"). | |
| 1626 | // | |
| 1627 | // Now the node answers from what it already knows: the inbound lens folds replies that touch our | |
| 1628 | // content into the appview, and ap-bridge keeps what the fediverse sends. No credential, works with | |
| 1629 | // JavaScript off. | |
| 1630 | // | |
| 1631 | // ⚠ `apAvailable === false` is NOT "no fediverse replies" — it is "this node cannot see them", | |
| 1632 | // because ap-bridge is single-instance and may live on another host. Saying so is the difference | |
| 1633 | // between an empty page that is true and one that is a lie. | |
| 1634 | export function notificationsPage ({ viewer = null, pds = 'https://pds.7rnx.net', items = [], apAvailable = null } = {}) { | |
| 1635 | const list = items.length | |
| 1636 | ? items.map(notifRow).join('') | |
| 1637 | : '<p class="empty">Nothing yet — replies, likes and boosts that touch your posts appear here.</p>' | |
| 1638 | const apNote = apAvailable === false | |
| 1639 | ? '<p class="scopenote">⚠ The ActivityPub side is not readable from this node, so fediverse replies are not shown here. They are not missing — this node cannot see them.</p>' | |
| 1640 | : '' | |
| 1641 | const body = `<div class="viewhdr">Notifications <span class="scopenote">· from this node, no credential</span></div> | |
| 1642 | ${apNote} | |
| 1643 | <div class="notiflist">${list}</div> | |
| 1644 | <details class="bskybox"><summary>also fetch bsky's own notifications in this browser</summary> | |
| 1610 | 1645 | <noscript><p class="empty">Notifications need JavaScript. Everything else in favela works without it — this page is the exception, because the server holds no credential of yours and so cannot fetch them for you.</p></noscript> |
| 1611 | 1646 | <form id="notif-login" class="authbox" hidden> |
| 1612 | 1647 | <p class="empty">Sign in with an <strong>app password</strong> — not your account password. It is used once to mint a session and is never stored; only the short-lived tokens are kept, in this browser.</p> |
| … | … | @@ -1616,7 +1651,8 @@ export function notificationsPage ({ viewer = null, pds = 'https://pds.7rnx.net' |
| 1616 | 1651 | </form> |
| 1617 | 1652 | <p id="notif-status" aria-live="polite" class="empty"></p> |
| 1618 | 1653 | <div id="notif-list"></div> |
| 1619 | <p><button id="notif-signout" class="devbtn">forget this browser\'s notification session</button></p>` | |
| 1654 | <p><button id="notif-signout" class="devbtn">forget this browser\'s notification session</button></p> | |
| 1655 | </details>` | |
| 1620 | 1656 | return page({ |
| 1621 | 1657 | title: 'notifications · favela', |
| 1622 | 1658 | viewer, |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-web", |
| 3 | "version": "0.3.61", | |
| 3 | "version": "0.3.62", | |
| 4 | 4 | "description": "favela-web — the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "bin/favela-web.js", |
| … | … | @@ -55,7 +55,7 @@ |
| 55 | 55 | "spike:seam": "node spike/atcute-seam.mjs", |
| 56 | 56 | "spike:p2p": "node spike/p2p-read.mjs", |
| 57 | 57 | "web": "bare bin/favela-web.js", |
| 58 | "test": "node test/render.test.mjs && node test/static-assets.test.mjs && node test/resize-plan.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs", | |
| 58 | "test": "node test/render.test.mjs && node test/static-assets.test.mjs && node test/resize-plan.test.mjs && node test/notifications.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs", | |
| 59 | 59 | "test:ws": "bare test/subscribe-labels-ws.test.mjs", |
| 60 | 60 | "test:bare": "bare test/appview.test.mjs && bare test/appview-ingest.test.mjs && bare test/appview-dedup.test.mjs && bare test/appview-no-clobber.test.mjs && bare test/appview-blocks.test.mjs && bare test/appview-thread.test.mjs && bare test/appview-author-filter.test.mjs && bare test/appview-collections.test.mjs", |
| 61 | 61 | "spike:pack": "bare spike/mobile-pack-probe.mjs", |
| … | … | @@ -0,0 +1,133 @@ |
| 1 | // "Did anyone reply to me" — from this node's own knowledge, both networks, no second credential. | |
| 2 | // | |
| 3 | // The page this feeds used to be a shell that asked the browser for a bsky APP PASSWORD and fetched | |
| 4 | // bsky's notification API client-side: a second credential for an account already controlled here, a | |
| 5 | // view that was bsky's rather than ours, and total blindness to ActivityPub. hyper-social #45 listed | |
| 6 | // it among the five gaps that decide whether favela is a product. | |
| 7 | import { fromApItem, fromReply, mergeNotifications, actorHandle, htmlToText } from '../lib/notifications.js' | |
| 8 | ||
| 9 | let pass = 0 | |
| 10 | const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ } | |
| 11 | const eq = (a, b, m) => ok(a === b, `${m} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`) | |
| 12 | ||
| 13 | // A real item, copied from what ap-bridge actually stored when prsm.space replied on 2026-08-18. | |
| 14 | const AP_REPLY = { | |
| 15 | id: 'https://prsm.space/users/fleeky/statuses/117118370829261804/activity', | |
| 16 | type: 'Create', | |
| 17 | actor: 'https://prsm.space/users/fleeky', | |
| 18 | objectId: 'https://prsm.space/users/fleeky/statuses/117118370829261804', | |
| 19 | inReplyTo: 'https://ap.7rnx.net/ap/objects/3mtewvmoqec22', | |
| 20 | 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>', | |
| 21 | url: 'https://prsm.space/@fleeky/117118370829261804', | |
| 22 | published: '2026-08-18T20:21:55Z', | |
| 23 | receivedAt: 1787084518154 | |
| 24 | } | |
| 25 | ||
| 26 | // — actor names — | |
| 27 | eq(actorHandle('https://prsm.space/users/fleeky'), '@fleeky@prsm.space', 'a fediverse actor reads as the handle its owner knows') | |
| 28 | eq(actorHandle('nonsense'), 'nonsense', 'an unparseable actor is passed through, not crashed on') | |
| 29 | ||
| 30 | // — remote HTML is TEXT here — | |
| 31 | // ⛔ ap-bridge stores content verbatim, which is right: it is not the store's job to decide how it | |
| 32 | // renders. A notification line is not the place to reproduce someone else's markup. | |
| 33 | eq(htmlToText('<p>hello<br />there</p>'), 'hello there', 'tags become spaces') | |
| 34 | ok(!htmlToText(AP_REPLY.content).includes('<'), 'no markup survives into a notification line') | |
| 35 | 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') | |
| 36 | ||
| 37 | // — the AP reply — | |
| 38 | { | |
| 39 | const n = fromApItem(AP_REPLY) | |
| 40 | eq(n.kind, 'reply', 'Create{Note} is a reply') | |
| 41 | eq(n.network, 'activitypub', 'and it is labelled as fediverse') | |
| 42 | eq(n.who, '@fleeky@prsm.space') | |
| 43 | eq(n.when, '2026-08-18T20:21:55Z', 'the author time, not when we happened to receive it') | |
| 44 | eq(n.subject, 'https://ap.7rnx.net/ap/objects/3mtewvmoqec22', 'which of OUR posts it answers') | |
| 45 | ok(n.text.includes('can of air'), 'and what they said') | |
| 46 | } | |
| 47 | ||
| 48 | // Likes and boosts carry no text and must not pretend to. | |
| 49 | eq(fromApItem({ type: 'Like', actor: 'https://a.net/users/b', objectId: 'https://ap.7rnx.net/ap/objects/x' }).kind, 'like', 'Like') | |
| 50 | eq(fromApItem({ type: 'Announce', actor: 'https://a.net/users/b', objectId: 'https://ap.7rnx.net/ap/objects/x' }).kind, 'boost', 'Announce') | |
| 51 | eq(fromApItem({ type: 'Like', actor: 'https://a.net/users/b' }).text, '', 'a like has no text') | |
| 52 | eq(fromApItem({ type: 'Follow', actor: 'https://a.net/users/b' }), null, 'a Follow is not presented here (yet) rather than mislabelled') | |
| 53 | eq(fromApItem(null), null, 'nothing in, nothing out') | |
| 54 | ||
| 55 | // — the atproto reply — | |
| 56 | { | |
| 57 | 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') | |
| 58 | eq(n.kind, 'reply') | |
| 59 | eq(n.network, 'atproto', 'labelled as atproto — a reply from Mastodon and one from bsky are not interchangeable') | |
| 60 | eq(n.who, 'alice.example') | |
| 61 | eq(n.subject, 'at://did:plc:wj62/app.bsky.feed.post/p1') | |
| 62 | } | |
| 63 | ||
| 64 | // A handle-less author falls back to a SHORT did, never 32 raw characters. | |
| 65 | { | |
| 66 | const n = fromReply({ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', uri: 'at://x/y/z', record: { text: 'hi' } }, 'at://s') | |
| 67 | eq(n.who, 'wj62ebbu…ogzo', 'readable, not decoration') | |
| 68 | } | |
| 69 | ||
| 70 | // — the merge — | |
| 71 | { | |
| 72 | const merged = mergeNotifications({ | |
| 73 | atproto: [fromReply({ did: 'did:plc:alice', handle: 'alice.example', uri: 'at://a/1', record: { text: 'older', createdAt: '2026-08-18T09:00:00Z' } }, 'at://s')], | |
| 74 | activitypub: [fromApItem(AP_REPLY)] | |
| 75 | }) | |
| 76 | ok(merged.length === 2, 'both networks in ONE list — two inboxes is how you get a confused user') | |
| 77 | eq(merged[0].network, 'activitypub', 'newest first, regardless of source') | |
| 78 | eq(merged[1].when, '2026-08-18T09:00:00Z') | |
| 79 | } | |
| 80 | ||
| 81 | // ⛔ Your own replies are indexed exactly like anyone else's, so without this the page fills with | |
| 82 | // your own voice. | |
| 83 | { | |
| 84 | 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' } | |
| 85 | 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' } | |
| 86 | const merged = mergeNotifications({ atproto: [mine, theirs], viewerDid: 'did:plc:me' }) | |
| 87 | eq(merged.length, 1, 'your own reply is not a notification') | |
| 88 | eq(merged[0].who, 'alice') | |
| 89 | } | |
| 90 | ||
| 91 | // The same reply can arrive twice — a fediverse reply that also federates back, or both spacedust | |
| 92 | // subject paths hitting one record. | |
| 93 | { | |
| 94 | const a = fromApItem(AP_REPLY) | |
| 95 | const merged = mergeNotifications({ activitypub: [a, { ...a }] }) | |
| 96 | eq(merged.length, 1, 'deduped by id') | |
| 97 | } | |
| 98 | ||
| 99 | // An undated item sorts last rather than vanishing: "we do not know when" is not "it did not happen". | |
| 100 | { | |
| 101 | const undated = { kind: 'like', network: 'activitypub', who: '@x@y.z', when: null, id: 'u1' } | |
| 102 | const dated = fromApItem(AP_REPLY) | |
| 103 | const merged = mergeNotifications({ activitypub: [undated, dated] }) | |
| 104 | eq(merged.length, 2) | |
| 105 | eq(merged[1].id, 'u1', 'undated last, still present') | |
| 106 | } | |
| 107 | ||
| 108 | // ── the AP inbox reader ───────────────────────────────────────────────────────────────────────── | |
| 109 | // | |
| 110 | // ⛔ null vs [] IS THE WHOLE CONTRACT. ap-bridge is single-instance, so a favela-web on any other | |
| 111 | // host cannot read it. "You have no replies" and "this node cannot see the fediverse" must not | |
| 112 | // render alike — the same distinction the AP reach reader already draws. | |
| 113 | { | |
| 114 | const { createApInbox } = await import('../lib/ap-inbox.js') | |
| 115 | ||
| 116 | const okClient = { call: async () => ({ ok: true, items: [AP_REPLY] }), close: async () => {} } | |
| 117 | const got = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => okClient }).list({}) | |
| 118 | eq(Array.isArray(got) && got.length, 1, 'items when the bridge answers') | |
| 119 | ||
| 120 | const none = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => ({ call: async () => ({ ok: true, items: [] }), close: async () => {} }) }).list({}) | |
| 121 | eq(Array.isArray(none) && none.length, 0, 'EMPTY when it answered and had nothing') | |
| 122 | ||
| 123 | const noSock = await createApInbox({ sockPath: '/s', exists: () => false, connect: async () => okClient }).list({}) | |
| 124 | eq(noSock, null, 'NULL when there is no ap-bridge here at all') | |
| 125 | ||
| 126 | const broke = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => { throw new Error('ECONNREFUSED') } }).list({}) | |
| 127 | eq(broke, null, 'NULL when it is down — never a throw, the atproto half must still render') | |
| 128 | ||
| 129 | const oldVersion = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => ({ call: async () => { throw new Error('unknown method') }, close: async () => {} }) }).list({}) | |
| 130 | eq(oldVersion, null, 'NULL against an ap-bridge too old to have the verb') | |
| 131 | } | |
| 132 | ||
| 133 | console.log(`\nNOTIFICATIONS OK: ${pass} assertions`) |
| … | … | @@ -630,11 +630,17 @@ has(shell2, 'a:focus-visible,button:focus-visible', 'every interactive element g |
| 630 | 630 | |
| 631 | 631 | |
| 632 | 632 | // ── notifications ──────────────────────────────────────────────────────────────────────────────── |
| 633 | // The server renders the SHELL ONLY. hpp requires a real user bearer to mint service auth — deliberately, | |
| 634 | // so an allowlisted peer cannot mint a signing oracle with nobody present — and holding that bearer here | |
| 635 | // would make favela-web a credential store, which is the thing it is built not to be. | |
| 633 | // ⛔ THE SERVER NOW ANSWERS. This page used to render a shell and ask the BROWSER for a bsky app | |
| 634 | // password, because the node holds no credential of yours — true, and beside the point: it does not | |
| 635 | // need one. The inbound lens already folds replies that touch our content into the appview, and | |
| 636 | // ap-bridge keeps what the fediverse sends. #45 listed "the 'did anyone reply to me' loop does not | |
| 637 | // work" among the five gaps that decide whether this is a product. | |
| 638 | // | |
| 639 | // The bsky-app-password path is NOT deleted — it fetches something we genuinely cannot (bsky's own | |
| 640 | // notification state, incl. things touching accounts we do not host) — it is demoted into a | |
| 641 | // <details> so the default page is the one that needs no credential. | |
| 636 | 642 | const np = notificationsPage({ viewer, pds: 'https://pds.example' }) |
| 637 | has(np, 'fetched by your browser, not by this node', 'the page states where the fetch happens') | |
| 643 | has(np, 'from this node, no credential', 'the page states where the answer comes from') | |
| 638 | 644 | has(np, 'id="notif-login"', 'and offers a sign-in form') |
| 639 | 645 | has(np, 'app password</strong>', 'asking for an APP password, not the account password') |
| 640 | 646 | has(np, 'never stored', 'and says the password is not stored') |
| … | … | @@ -643,6 +649,27 @@ has(np, 'name="favela-pds" content="https://pds.example"', 'the PDS origin is pa |
| 643 | 649 | has(np, '/static/notifications.js', 'and the client script is loaded') |
| 644 | 650 | // It is the ONE page that needs JS, so it says so instead of appearing broken. |
| 645 | 651 | has(np, '<noscript>', 'it degrades with an explanation, not silence') |
| 652 | ||
| 653 | // The real list, server-rendered. | |
| 654 | { | |
| 655 | const n = (over = {}) => ({ kind: 'reply', network: 'activitypub', who: '@fleeky@prsm.space', when: '2026-08-18T20:21:55Z', text: 'this computer needs a can of air', url: 'https://prsm.space/@fleeky/1', id: '1', ...over }) | |
| 656 | const withItems = notificationsPage({ viewer, items: [n()] }) | |
| 657 | has(withItems, '@fleeky@prsm.space', 'the author is named') | |
| 658 | has(withItems, 'can of air', 'and what they said is rendered by the SERVER — no JS needed') | |
| 659 | has(withItems, 'replied', 'the verb says what happened') | |
| 660 | has(withItems, 'fediverse', 'and the network is badged — a Mastodon reply is not a bsky reply') | |
| 661 | has(notificationsPage({ viewer, items: [n({ network: 'atproto', who: 'alice.example' })] }), 'bsky', 'the other network is badged too') | |
| 662 | ||
| 663 | // Remote text is escaped at render even though notifications.js already stripped tags: the store | |
| 664 | // keeps content verbatim by design, so this is the second line of defence, not the first. | |
| 665 | const nasty = notificationsPage({ viewer, items: [n({ text: '<img src=x onerror=alert(1)>' })] }) | |
| 666 | not(nasty, '<img src=x', 'remote content cannot inject markup') | |
| 667 | ||
| 668 | // ⛔ "no replies" and "this node cannot see the fediverse" are different pages. | |
| 669 | has(notificationsPage({ viewer, items: [], apAvailable: false }), 'cannot see them', 'an unreadable AP side SAYS so') | |
| 670 | not(notificationsPage({ viewer, items: [], apAvailable: true }), 'cannot see them', 'and stays quiet when it can see') | |
| 671 | has(notificationsPage({ viewer, items: [] }), 'Nothing yet', 'a genuinely empty list reads as empty') | |
| 672 | } | |
| 646 | 673 | has(np, 'Everything else in favela works without it', 'naming this as the deliberate exception') |
| 647 | 674 | // The server must never be handed the credential. |
| 648 | 675 | not(np, 'action="/api/', 'the form posts to NO favela endpoint — the browser talks to the PDS directly') |