// Pages — pure functions from daemon data to HTML. No I/O, so they are testable without a socket
// or a server, which is where nearly all the behaviour worth asserting lives.
//
// ⛔ Every value that reaches the page is escaped here or rendered by favela-markdown's render(),
// which escapes. Nothing interpolates raw. The one exception is `render()`'s output itself, which is
// HTML by contract and is the only trusted producer in the process.
import { render } from 'favela-markdown/lib/render.js'
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
/**
* Stamp a rendered page with a forced theme.
*
* ⛔ Applied at SEND time, not threaded through every page function and not held in a module
* variable. A module-level "current theme" is request state in a shared place, and two interleaved
* requests would hand one reader the other's setting.
*
* ⚠ null is SYSTEM and is the default. A site that picks for you and calls it a preference is worse
* than one that has none, so an untouched page follows prefers-color-scheme.
*/
export function withTheme (html, theme) {
if (theme !== 'light' && theme !== 'dark') return html
return html.replace('<html lang="en">', '<html lang="en" data-theme="' + theme + '">')
}
/**
* A banner on EVERY page when an update is downloaded and waiting.
*
* ⛔ APPLIED AT SEND TIME, exactly like `withTheme`, so no page function has to know this exists and
* none can forget to render it. A notice that only appears where someone remembered to put it is a
* notice nobody sees on the page they were actually on.
*
* ⛔ STAGED-AND-DIFFERENT ONLY. Staged and identical means the node re-verified the bundle it is
* already running, which is the ordinary case — announcing that as an update would train people to
* ignore the banner, and then the real one does nothing.
*
* ⚠ It links to settings rather than restarting from here. Restarting is a thing the SHELL does and
* only the shell knows whether it can; a banner that offered a button the shell cannot honour would
* be the same defect `parseShell` keeps separate flags to avoid.
*/
/**
* Is a downloaded update actually waiting to be applied?
*
* ⛔ `running === null` MEANS "WE BOOTED THE APK'S OWN BUNDLE", and it is the normal state of a
* freshly installed app — `chooseBundle` returns `hash: null` for the asset, so the shell has no
* hash to report. Requiring `running` to be set therefore made the answer NO on exactly the devices
* that most need it: the phone staged an update correctly and then rendered "Up to date", with no
* banner, which is what "check now does nothing" looked like from the outside.
*
* Anything staged while we are running the asset is by construction not what booted, so it IS
* waiting. When `running` IS known, the comparison is the real one — staged and identical means the
* node re-verified what it is already running, which is the ordinary case and not an update.
*/
export function updateWaiting (u) {
if (!u || !u.staged) return false
// ⛔ THE SHELL'S VERDICT WINS, AND THIS IS WHY IT EXISTS. Observed on a handset: this notice shown
// for ever while restarting changed nothing. The rules below re-derive a decision this page does not
// own — chooseBundle also refuses a CONDEMNED bundle, and failed.json lives on the shell side where a
// page cannot see it, so no amount of build-time arithmetic here could ever have got that case right.
// The shell runs chooseBundle at boot and now reports what it decided; if it declined the bundle that
// is staged, no restart can help and offering one is a button that does nothing.
if (u.refused && u.refused.hash === u.staged) return false
if (u.running) return u.staged !== u.running
// ⛔ `running` NULL MEANS WE BOOTED THE APK'S OWN BUNDLE — and that does NOT automatically make a
// staged bundle an update. `chooseBundle` prefers the ASSET when the app was installed more
// recently than the staged bundle was built, so a staged-but-older bundle will never be applied
// and a banner offering to restart for it is a button that does nothing, for ever.
//
// ⚠ This is the correction to my own earlier fix. Requiring `running` hid real updates on fresh
// installs; assuming any staged bundle beats the asset produced a notice that could not be
// dismissed. The honest rule is the one chooseBundle uses: compare BUILD TIMES, and say nothing
// when the answer is "the app is already newer".
const staged = Number(u.stagedBuiltAt) || 0
const shipped = Number(u.builtAt) || 0
if (staged && shipped) return staged > shipped
// Neither side can be dated: fall back to announcing it, since a staged bundle with no ordering
// is what chooseBundle also treats as a candidate.
return true
}
/**
* Why a staged bundle will not be applied, or null when nothing is blocking.
*
* The counterpart to updateWaiting: a page that merely goes quiet leaves someone staring at a version
* number that never changes. Saying "this update needs a newer app" is a sentence they can act on.
*/
export function updateBlockedReason (u) {
if (!u || !u.staged) return null
if (!u.refused || u.refused.hash !== u.staged) return null
// ⛔ SUPPRESS A REFUSAL FOR A BUNDLE OLDER THAN THE ONE RUNNING, page-side as well as shell-side.
// boot-plan.js is React Native and ships in the APK; this page ships in the BUNDLE and arrives over
// OTA. A shell too old to suppress a stale refusal keeps sending one, and on the handset that pinned
// "this update cannot be applied — staged bundle previously failed at boot" to every page while a
// NEWER bundle was running. Waiting for an APK to clear a banner is the trap this work exists to
// escape, so the rule is applied wherever it can be.
const staged = Number(u.stagedBuiltAt) || 0
const running = Number(u.builtAt) || 0
if (staged && running && staged <= running) return null
return u.refused.reason || 'refused by the shell'
}
export function withUpdateBanner (html, update) {
const u = update || null
// ⛔ A BLOCKED UPDATE STILL GETS A LINE, and it does NOT offer a restart. Going silent would leave
// someone watching a version number that never changes with nothing to read; the old behaviour —
// offering a restart that cannot work — was worse, but silence is not the fix. "This update needs a
// newer app" is a sentence a person can act on.
const blocked = updateBlockedReason(u)
if (blocked) {
const line = '<div class="update-bar update-bar-blocked"><a href="/settings">This update cannot be applied — ' +
esc(blocked) + '</a></div>'
return html.includes('<body>') ? html.replace('<body>', '<body>' + line) : line + html
}
if (!u || !updateWaiting(u)) return html
const bar = '<div class="update-bar"><a href="/settings">An update is ready — restart to apply</a></div>'
// After the opening <body>, so it is the first thing in the document and cannot be pushed below
// the fold by whatever the page happens to render first.
return html.includes('<body>') ? html.replace('<body>', '<body>' + bar) : bar + html
}
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
const short = (k) => esc(String(k || '').slice(0, 8))
/**
* Who wrote something — three tiers, rendered as three weights.
*
* @alice0 the HANDLE claimed in a namespace, unique within it, attested
* Alice Smith the SELF-LABEL what this feed calls itself; unique to nobody
* fd9ba4c6 the KEY the only unforgeable thing on the row
*
* ⛔ That is NAMING.md §3's resolution order — handle, then self-label, then key — made visible
* rather than merely implemented. A reader can see which of the three they are trusting.
*
* ⛔ The KEY is always shown, even beside both others. A self-label is a CLAIM by its holder,
* nothing enforces uniqueness and nothing can; hiding the key behind a name would make
* impersonation a matter of typing.
*
* ⛔ A handle arrives here only if the daemon CONFIRMED it — the claim's identity root matches the
* feed's own founder. Anyone can claim a name pointing at anyone's feed key, so an unchecked handle
* in a byline would let a stranger rename someone by typing. `confirmedHandles` refuses it upstream;
* this function must never reintroduce it by reading a handle from anywhere else.
*/
export function who (author, profiles, { avatar = true } = {}) {
const p = (profiles && profiles[author]) || null
/**
* ⛔ A PLACEHOLDER WHEN THERE IS NO PICTURE, never nothing.
*
* The avatar used to be omitted entirely for a feed that had published none, so a list of people
* indented the ones with pictures and left the rest flush — every row a different shape, and the
* whole list reading as loose text instead of rows. Post rows never had this problem because the
* article grid always draws its circle. Mastodon and Bluesky both draw a default face; neither
* has a row that starts with a name.
*/
const img = !avatar
? ''
: (p && p.avatar
? '<img class="av" src="/avatar/' + esc(p.avatar) + '" alt="">'
: '<span class="av av-dot"></span>')
const handle = p && p.handle ? '<span class="handle">@' + esc(p.handle) + '</span> ' : ''
// The self-label is not bold when a handle is present: two names in one weight would read as one
// name, and they are different KINDS of claim.
const label = p && p.name
? (handle
? '<span class="label">' + esc(p.name) + '</span> '
: '<span class="handle">' + esc(p.name) + '</span> ')
: ''
// ⛔ The key is a LINK, not decoration. It is the only unforgeable thing on the row, so it is both
// always shown and the way to look someone up.
// ⛔ Only the NAME links. Wrapping the avatar, the handle, the self-label and the key in one
// anchor underlined all of it and made three different kinds of claim read as one run-on name.
// The key is still a link — it is how you look someone up — but it is its own one.
const name = handle || label
return '<span class="byline">' +
'<a href="/u/' + esc(author) + '">' + img + (name || '') + '</a>' +
(handle && label ? label : '') +
'<a class="key" href="/u/' + esc(author) + '"><code>' + short(author) + '</code></a>' +
'</span>'
}
/**
* A full key, out of the reading flow and never out of reach.
*
* ⛔ DEMOTED, NOT DELETED. Five pages printed a 64-character key as body text, where it wraps across
* the column and outweighs everything a reader came for — the one thing neither Bluesky nor Mastodon
* does anywhere. But `who()` above says why a key is shown at all: a self-label is a claim, nothing
* enforces uniqueness, and hiding the key behind a name makes impersonation a matter of typing. That
* argument holds for the full key too, which is also how you verify a stranger, follow a feed and
* join a room. So it collapses; it does not disappear.
*
* ⚠ `<details>` — no JavaScript. Every other page here works with scripting off and this must too,
* and a copy button would be the first thing on the surface that did not.
*/
export function keyline (label, key) {
return '<details class="keyline"><summary>' + esc(label) + '</summary>' +
'<code>' + esc(key) + '</code></details>'
}
/**
* What a page says when it has nothing to show.
*
* ⛔ "Nothing yet." is the one thing the reader already knows. An empty state has three jobs: name
* what is empty, say WHY it is empty, and offer the thing that fixes it. Bluesky's empty feed does
* all three and puts two buttons on the page; ours did none of them.
*
* ⚠ `why` is escaped, and actions are {href, label} rather than markup — an empty state is the
* place a caller is most tempted to pass a sentence with a link in it, and this file has already
* been bitten by markup arriving through a value.
*/
export function empty (title, why, actions = []) {
// ⚠ A next step is sometimes a POST — "make a room" is not a page you can navigate to. So an
// action is {href} OR {post}, and this renders the right element for each. Still no markup
// arriving through a value: the caller passes a URL and a label, never a fragment.
const links = actions.length
? '<div class="choices">' + actions.map((a) => (a.post
? '<form method="post" action="' + esc(a.post) + '"><button>' + esc(a.label) + '</button></form>'
: '<a class="file" href="' + esc(a.href) + '">' + esc(a.label) + '</a>')).join(' ') + '</div>'
: ''
return '<div class="empty"><p class="empty-title">' + esc(title) + '</p>' +
(why ? '<p class="who">' + esc(why) + '</p>' : '') + links + '</div>'
}
const CSS = `
/* ⛔ ONE colour and ONE spacing scale, as variables. Values scattered through rules is how a page
ends up with four greys that were each "close enough" at the time. */
/**
* ⛔ Plain values, not light-dark(). The whole palette used one function, so a browser without it
* loses EVERY colour at once — and there is no partial failure to notice, the page just renders in
* initial values. The classic three-block form works everywhere and says the same thing.
*
* Light is the base. Dark applies when the reader's device asks for it AND they have not chosen
* light; an explicit choice of dark applies always. That ordering is what makes the setting a
* choice rather than a hint.
*/
:root{
color-scheme:light dark;
--fg:#16181c; --dim:#5b6570; --line:#e1e4e8; --accent:#1d6fd4;
--bg:#ffffff; --bg-soft:#f6f8fa;
/* Edge colours, declared ONCE and indirected through the palette where they follow it. Both the
2D view and the 3D one read these, so a repost cannot be green in one picture and grey in the
other after somebody edits a single rule. */
--edge-reaction:var(--accent); --edge-repost:#2f9c63; --edge-reply:#b96a1f;
--edge-follow:var(--dim); --edge-authored:var(--dim);
--s1:.35rem; --s2:.7rem; --s3:1.1rem; --s4:1.8rem;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme=light]){
color-scheme:dark;
--fg:#e7e9ea; --dim:#8b98a5; --line:#2f3336; --accent:#6aa9f0;
--bg:#0d0e10; --bg-soft:#16181c;
}
}
:root[data-theme=dark]{
color-scheme:dark;
--fg:#e7e9ea; --dim:#8b98a5; --line:#2f3336; --accent:#6aa9f0;
--bg:#0d0e10; --bg-soft:#16181c;
}
:root[data-theme=light]{color-scheme:light}
body{font:16px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;max-width:38rem;margin:0 auto;
padding:var(--s3) var(--s3) 6rem;color:var(--fg);background:var(--bg);min-height:100vh}
/* Navigation reads as one row of peers, not eight loose links. No underline until you reach for it,
and the page you are on is marked — otherwise the nav tells you nothing about where you are. */
/* ⛔ THE ROW WAS FIXED THREE TIMES BY SHRINKING THE GAP, and wrapped again at eleven items.
"Eight items on one line at this width" became nine, then ten, each time leaving one orphaned
link on a second row. A row that must fit N items breaks every time N grows, so the fourth fix is
not a smaller gap: it is a layout that has no N. Beside the column the nav stacks, and stacking
cannot wrap however many pages are added.
⚠ The ROW IS STILL THE DEFAULT. A phone has no gutter to put a sidebar in, so the row is what
works everywhere and the sidebar is what a wide viewport earns. */
nav{display:flex;flex-wrap:wrap;gap:.55rem;border-bottom:1px solid var(--line);
padding-bottom:var(--s2);margin-bottom:var(--s3);font-size:13.5px}
nav a{margin:0;text-decoration:none;color:var(--dim)}
nav a:hover{color:var(--fg);text-decoration:underline}
nav a[aria-current]{color:var(--fg);font-weight:600}
/* ⛔ Beside the column, not above it.
The reading column stays 38rem — the measured match for Bluesky's and Mastodon's 600px — and the
nav takes 11rem of the gutter that was empty either side of it. It aligns to the start so a short
nav does not stretch down a long page, and sticks so it is still there after you scroll, which is
the behaviour both references have and a top row cannot.
⛔ NO BACKTICKS IN THIS BLOCK. It lives in a JS template literal, so one backtick in a comment
ends the string and the module stops parsing. Third time in this file; a test asserts it, but a
parse error beats the test to it — the suite cannot import what will not parse.
⚠ 62rem, because that is where 11rem + 38rem + the gap first fits without squeezing the column. */
@media (min-width:62rem){
body{max-width:52rem;display:grid;grid-template-columns:11rem minmax(0,38rem);
gap:var(--s4);align-items:start}
nav{flex-direction:column;align-items:stretch;gap:.1rem;border-bottom:0;padding-bottom:0;
margin-bottom:0;position:sticky;top:var(--s3);font-size:14.5px}
nav a{padding:.3rem .6rem;border-radius:999px}
nav a:hover{background:var(--bg-soft);text-decoration:none;color:var(--fg)}
nav a[aria-current]{background:var(--bg-soft)}
}
/* ⛔ The post is a GRID: a fixed avatar gutter and one content column, so every line of a post
shares a left edge and the identity never shifts the text. */
/* Measured against mastodon/mastodon components.scss, which is the same structure:
grid-template-columns: 46px minmax(0, 1fr), an 8px gutter (--thread-margin: 46px + 8px) and
16px padding. minmax(0,1fr) rather than 1fr is what stops a long unbroken word widening the
column instead of wrapping. */
/* ⚠ A post scrolled to by fragment sits flush against the top edge otherwise, which reads as the
page being cut off rather than as an arrival. */
article{scroll-margin-top:1rem;border-top:1px solid var(--line);padding:1rem 0;display:grid;
grid-template-columns:46px minmax(0,1fr);gap:8px}
.av-col{padding-top:.15rem}
.post-col{min-width:0}
img.av-lg-sm,.av-blank{width:46px;height:46px;border-radius:50%;object-fit:cover;display:block}
.av-blank{background:var(--line)}
/* ⛔ ONE line. The separator and the time used to fall to a second line with a dangling "·",
because '.byline' was a block-level flex and the text after it wrapped. */
.meta{display:flex;align-items:baseline;flex-wrap:wrap;gap:.4rem;font-size:14px;color:var(--dim);
margin-bottom:var(--s1)}
.meta time{font-variant-numeric:tabular-nums}
.meta .sep{opacity:.5}
/* ⛔ Monospace ONLY for a key. It is a hash and character-level legibility is the point. Everything
else that used this class — timestamps, help text, a bio — is PROSE, and setting prose in
monospace is most of why this page read as terminal output. */
.who{font-size:13px;color:var(--dim)}
code,.key{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
/* The byline: three kinds of claim, three weights, and only the handle is a link. Wrapping all of
it in one anchor underlined the lot and made it read as a single run-on name. */
.byline{display:inline-flex;align-items:baseline;gap:.4rem;font-size:14px;color:var(--dim)}
.byline a{text-decoration:none;color:inherit}
.byline a:hover{text-decoration:underline}
.byline .handle{color:var(--fg);font-weight:600}
.label{font-weight:400;color:var(--dim)}
.byline .key{color:var(--dim);opacity:.75}
.hashtag,.wikilink,.mention{color:var(--accent);text-decoration:none}
form{margin:var(--s3) 0}
textarea,input[type=text]{width:100%;font:inherit;padding:var(--s2);border:1px solid var(--line);
border-radius:8px;background:transparent;color:inherit}
textarea:focus,input[type=text]:focus{outline:2px solid var(--accent);outline-offset:-1px}
button{font:inherit;padding:.4rem .9rem;border:1px solid var(--line);border-radius:999px;
background:transparent;color:inherit;cursor:pointer}
button:hover{border-color:var(--dim)}
/* Liked is a STATE, so it has to look different from the offer to like. Colour alone would fail a
reader who cannot see it; the glyph carries the same information. */
button.liked{font-weight:700;border-color:var(--accent);color:var(--accent)}
/* The composer is not the page. It was a third of the first screen before any content: a tall box,
a permanent file input and two lines of help that never changed. */
.banner-lg{width:100%;max-height:190px;object-fit:cover;border-radius:10px;border:1px solid var(--line)}
.composer{background:var(--bg-soft);border:1px solid var(--line);border-radius:12px;
padding:var(--s2);margin-bottom:var(--s4)}
.composer textarea{border:0;padding:var(--s1);min-height:3.2rem;resize:vertical}
.composer textarea:focus{outline:0}
.composer .row{display:flex;align-items:center;justify-content:space-between;gap:var(--s2)}
.composer .post{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
/* ⚠ The help still exists — it says SVG is refused and that the type is sniffed, which people need
BEFORE they pick a file. It just stops shouting: <details> keeps it one click away. */
.composer details{font-size:13px;color:var(--dim);margin-top:var(--s1)}
.composer summary{cursor:pointer}
/* The @-picker. Anchored to the composer, which is why it is inside it. */
.composer{position:relative}
#at{position:absolute;z-index:5;left:var(--s2);right:var(--s2);top:3.4rem;margin:0;padding:.25rem;
list-style:none;background:var(--bg);border:1px solid var(--line);border-radius:8px;
box-shadow:0 6px 24px #0004;max-height:14rem;overflow:auto}
#at li{padding:.35rem .6rem;border-radius:6px;cursor:pointer;font-size:14px}
#at li span{color:var(--dim)}
#at li.on{background:var(--bg-soft)}
/* ⛔ The only unstyled control on the page: the browser draws an OS button and the words "No file
chosen", and it is most of why the composer read as unfinished. ::file-selector-button styles the
button and KEEPS the filename — the hidden-input-and-label trick throws that away, and with no
JavaScript nothing else can tell you which file you picked. */
input[type=file]{font-size:13px;color:var(--dim);max-width:100%}
input[type=file]::file-selector-button{font:inherit;padding:.35rem .8rem;margin-right:.6rem;
border:1px solid var(--line);border-radius:999px;background:transparent;color:var(--fg);
cursor:pointer}
input[type=file]::file-selector-button:hover{border-color:var(--dim);background:var(--bg-soft)}
.composer input[type=file]{max-width:14rem}
.composer input[type=file]::file-selector-button{font:inherit;border:1px solid var(--line);
background:transparent;color:var(--dim);border-radius:999px;padding:.25rem .7rem;margin-right:.5rem;
cursor:pointer}
.composer .post{border-radius:999px;padding:.45rem 1.2rem}
.bio{margin:var(--s1) 0}
/* A field and its verb belong on one line — stacked, a one-field form looks like a page section. */
.scene{width:100%;height:60vh;min-height:22rem;border:1px solid var(--line);border-radius:8px;
background:var(--bg-soft);touch-action:none;cursor:grab;overflow:hidden;position:relative}
/* Block-level, or the canvas sits on a text baseline and overflows the box it was sized to fit. */
.scene canvas{display:block}
/* The update notice. Deliberately loud — it is the one thing on the page that expires. */
.update-bar{background:var(--warn-bg,#8a5a00);text-align:center;padding:.5rem}
.update-bar a{color:#fff;text-decoration:none;font-weight:600}
/* Inline state words in the status table — loud enough to find, quiet enough to read past. */
.warn-inline{color:var(--warn,#c88)}
table{border-collapse:collapse;width:100%}
td,th{text-align:left;padding:.25rem .5rem;vertical-align:top}
.ns-list{list-style:none;padding:0}
.ns-list li{margin:.35rem 0}
form.inline{display:inline}
.explorer{display:flex;flex-direction:column;gap:8px}
/* ⚠ Fullscreen paints its own black behind the element, so the ground has to be declared again or
a light-theme page turns into a light panel floating on black. */
.explorer:fullscreen{background:var(--bg);padding:12px;gap:10px}
.explorer:fullscreen .scene{height:auto;flex:1;min-height:0}
.scene-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:.82rem;color:var(--dim)}
.legend span{display:flex;align-items:center;gap:5px}
.legend i{display:inline-block;width:10px;height:10px;border-radius:50%}
.legend i.actor{background:var(--accent)}
.legend i.record{background:var(--dim)}
.legend i.gap{border:1px solid var(--dim);background:none}
.legend i.link{width:16px;height:0;border-radius:0;border-top:1px solid var(--dim)}
.legend i.weak{width:16px;height:0;border-radius:0;border-top:1px solid var(--dim);opacity:.35}
.legend i.e{width:16px;height:0;border-radius:0;border-top:2px solid var(--dim)}
.legend i.e-reaction{border-top-color:var(--edge-reaction)}
.legend i.e-repost{border-top-color:var(--edge-repost)}
.legend i.e-reply{border-top-color:var(--edge-reply)}
.legend i.e-follow{border-top-color:var(--edge-follow)}
.stats{display:flex;flex-wrap:wrap;gap:6px 18px;margin:0;font-size:.82rem;color:var(--dim)}
/* Each label+count is ONE flex item, or a wrap splits a pair across two lines. And dd carries a
40px inline-start margin by default, which pushed the last counts off the edge. */
.stats>div{display:flex;gap:6px;align-items:baseline}
.stats dt,.stats dd{margin:0}
.stats b{color:var(--fg);font-variant-numeric:tabular-nums}
/* The hover label. Absolutely placed inside the scene, which is why .scene is positioned. */
.tip{position:absolute;pointer-events:none;background:var(--bg);border:1px solid var(--line);
border-radius:6px;padding:3px 7px;font-size:.78rem;color:var(--fg);white-space:nowrap;z-index:2}
.scene:active{cursor:grabbing}
svg.graph{width:100%;height:auto;border:1px solid var(--line);border-radius:8px;background:var(--bg-soft)}
svg.graph text{font:11px ui-monospace,monospace;fill:var(--dim)}
svg.graph .e{stroke:var(--dim);stroke-width:1.2;opacity:.55}
svg.graph .e.reaction{stroke:var(--edge-reaction)}
svg.graph .e.repost{stroke:var(--edge-repost)}
svg.graph .e.reply{stroke:var(--edge-reply)}
svg.graph .e.follow{stroke:var(--edge-follow)}
svg.graph .e.authored{stroke:var(--edge-authored);opacity:.3}
/* ⛔ A dashed edge is a DIFFERENT FAILURE, not a faded one. Dash pattern rather than opacity alone,
so it survives a reader who cannot distinguish the colours. */
svg.graph .gap{stroke-dasharray:4 3;opacity:1}
svg.graph circle.a{fill:var(--accent)}
svg.graph circle.r{fill:var(--dim)}
svg.graph circle.r.gap{fill:none;stroke:var(--dim);stroke-dasharray:2 2}
ul.legend{list-style:none;padding:0;font-size:13px;color:var(--dim)}
ul.legend li{margin:.35rem 0}
.legend .k{display:inline-block;width:26px;border-top:2px solid var(--dim);vertical-align:middle;margin-right:.5rem}
.legend .k.dash{border-top-style:dashed}
.said{margin:var(--s3) 0}
.say{border-top:1px solid var(--line);padding:var(--s2) 0}
.say .meta{margin-bottom:.15rem}
/* A person and the button that acts on them belong on one line. */
li.ask{display:flex;align-items:center;gap:var(--s2);flex-wrap:wrap}
li.ask form{margin:0}
.choices{display:flex;gap:var(--s1);margin:var(--s2) 0}
.choices form{margin:0}
.inline-form{display:flex;gap:var(--s1)}
.inline-form input[type=text]{flex:1}
.inline-form button{white-space:nowrap}
ul.people{list-style:none;padding:0;margin:0}
ul.people li{border-top:1px solid var(--line);padding:var(--s2) 0}
/* ⛔ Actions are RANKED and QUIET. Seven bordered pills read as seven equal controls and dominated
the post they belonged to. Every mature client renders these as small, dim, borderless things
that come forward on hover — the post is the content, not its buttons. */
/* ⚠ 18px between actions and 16px above them — mastodon's own numbers (.status__action-bar).
Ours were 2px and 11px, which is why seven controls read as one cramped cluster rather than as
seven things you can choose between. */
/* ⛔ ONE toolbar, spread across the row — the shape of mastodon's .status__action-bar.
The palette used to be pushed to the far edge with margin-left:auto, so four words sat at one end
and three emoji at the other with a hole between them, and the post read as carrying two
unrelated sets of controls. It carries one. */
.reactions{display:flex;flex-wrap:wrap;gap:14px;align-items:center;margin-top:1rem;
justify-content:space-between}
.reactions form{margin:0;display:inline-flex}
.reactions button,.reactions .go{border:0;background:transparent;color:var(--dim);font-size:14px;
padding:.25rem .3rem;border-radius:6px;text-decoration:none;cursor:pointer}
.reactions button:hover,.reactions .go:hover{color:var(--fg);background:var(--bg-soft)}
.reactions button.liked{color:var(--accent);font-weight:600}
/* ⛔ A flex ITEM is not a flex container. The palette was a span holding inline-block buttons, so
they wrapped INSIDE it and stacked into a column down the right of every post. */
.reactions .quick{display:inline-flex;gap:10px}
/* Only the ones NOT yet added are dimmed. A reaction you hold is on, and dimming it would say the
opposite of what it means. */
.reactions .quick button{opacity:.55}
.reactions .quick button.liked{opacity:1}
.reactions .quick button:hover{opacity:1}
.warn{background:var(--bg-soft);border:1px solid var(--accent);color:var(--fg);
padding:var(--s2);border-radius:8px}
/* An inline note, for a state that is ordinary rather than alarming. */
.flag{font-size:12px;color:var(--dim);border:1px solid var(--line);border-radius:999px;
padding:.05rem .5rem;white-space:nowrap}
/* ⚠ The full key stays — it is the only unforgeable name on the page. It does not need to be the
loudest thing on it: dim and small, so it is available rather than shouting. */
/* An empty state is a block with room around it, not a stray sentence where content would be. */
.empty{border:1px solid var(--line);border-radius:12px;padding:var(--s4) var(--s3);
margin:var(--s3) 0;text-align:center;background:var(--bg-soft)}
.empty-title{font-weight:600;margin:0 0 var(--s1)}
.empty .who{margin:0 auto;max-width:26rem}
.empty .choices{justify-content:center;margin-top:var(--s3)}
.keyline{margin:var(--s1) 0;font-size:12px}
.keyline summary{color:var(--dim);cursor:pointer;list-style:none;width:fit-content}
.keyline summary::-webkit-details-marker{display:none}
/* A disclosure triangle drawn by hand, because the native one is inconsistent across browsers and
this sits inline with prose rather than heading a section. */
.keyline summary::before{content:'▸ ';display:inline-block;transition:transform .12s}
.keyline[open] summary::before{transform:rotate(90deg)}
.keyline code{display:block;color:var(--dim);word-break:break-all;margin-top:.25rem;
-webkit-user-select:all;user-select:all}
img.av,.av-dot{width:20px;height:20px;border-radius:50%;vertical-align:-4px;margin-right:.35rem;
object-fit:cover;flex:none}
.av-dot{display:inline-block;background:var(--line)}
img.av-lg{width:96px;height:96px;border-radius:50%;object-fit:cover}
label{display:block;margin:.4rem 0}
pre{overflow-x:auto;background:var(--bg-soft);padding:var(--s2);border-radius:8px}
table{border-collapse:collapse}td,th{border:1px solid var(--line);padding:.2rem .5rem}
h1,h2,h3{line-height:1.25;margin:var(--s3) 0 var(--s2)}
a{color:var(--accent)}
/* ⛔ An embed is a PREVIEW. Rendered at its natural size a photograph takes over the timeline —
a 4000px image is not a post, it is a wall — and everything below it stops being reachable.
Capped by VIEWPORT height rather than pixels so it stays a preview on a phone and on a desktop,
and \'object-fit:contain\' so nothing is cropped: a preview that hides part of the picture is
worse than one that is small. The <a> around it opens the full file, with no script. */
a.lightbox{display:block;margin:.7rem 0;line-height:0}
/* The lighttable — several attachments as one block instead of a vertical stack.
⛔ GRID, not CSS columns. CSS columns is the only masonry that ships today and it flows
down-then-across, so three attachments in two columns read 1,3,2 — reordering a sequence the
author chose. Order is worth more here than the ragged edge masonry would save.
The cost is object-fit cover, which crops: defensible for a CELL in a grid, where each is explicitly a
thumbnail and the anchor gives back the whole picture, in a way it was not for a lone image. */
.lighttable{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:.35rem;margin:.7rem 0}
.lighttable a.lightbox{margin:0}
.lighttable .embed{width:100%;aspect-ratio:1;max-height:none;object-fit:cover}
.embed{max-width:100%;height:auto;max-height:60vh;object-fit:contain;border-radius:4px;background:#8881}
video.embed{width:100%}
/* A file we cannot preview is still a file: make the link look like one rather than like prose. */
a.file{display:inline-block;padding:.3rem .6rem;border:1px solid var(--line);border-radius:8px;
margin:.3rem 0;text-decoration:none;color:inherit}
a.file:hover{border-color:var(--dim)}
embed.doc{width:100%;height:min(70vh,40rem);border:1px solid #8884;border-radius:4px}
`
/**
* The login page — the ONE route that is not gated, and the only page with JavaScript.
*
* ⛔ Reading favela needs no JS. Signing does: an ANProto challenge is answered with an Ed25519
* signature, and a browser cannot produce one from a form post. So the script here is the whole
* concession, it runs on this page only, and everything it does is visible in it.
*
* The keypair lives in localStorage. That IS the credential — there is no password and no expiry —
* so clearing site data means asking for a new invite, and a stolen key works until revoked. Both
* are stated on the page rather than left to be discovered.
*/
export function login (message, unclaimed = false) {
const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
// ⛔ TOFU. With nobody registered, the first browser to prove a key claims the instance — a fresh
// install has no way to mint an invite for itself. The offer disappears permanently on the first
// claim, and it is safe only because this binds localhost: whoever reaches the page first owns it.
const body = unclaimed
? '<h1>Claim this favela</h1>' +
'<p class="warn">Nobody has signed in yet. The first browser to do so claims this instance ' +
'permanently — everyone after needs an invite.</p>' +
'<form id="f"><button>claim</button></form>'
: '<h1>Sign in</h1>' +
'<p>Paste a one-time invite. Generate one on the machine running the daemon:</p>' +
'<pre>favela invite</pre>' +
'<form id="f"><input type="text" id="token" placeholder="invite token" autocomplete="off">' +
'<button>redeem</button></form>'
return layout('sign in', warn + body +
'<p id="msg" class="who"></p>' +
'<p class="who">Your browser keeps a key in local storage. It does not expire; ' +
'clearing site data means asking for a new invite.</p>' +
'<script type="module">\n' + LOGIN_JS + '\n</script>')
}
// Kept out of the template so the page above stays readable. It does three things: make or reuse a
// keypair, answer a challenge, and post the result.
const LOGIN_JS = `
import { ANProtoClient as an } from '/static/anproto-client.js'
const KEY = 'favela-keypair'
const msg = document.getElementById('msg')
async function keypair () {
let kp = localStorage.getItem(KEY)
if (!kp) { kp = await an.gen(); localStorage.setItem(KEY, kp) }
return kp
}
async function answer () {
const kp = await keypair()
const { challenge } = await (await fetch('/auth/challenge', { method: 'POST' })).json()
return { publicKey: an.getPublicKey(kp), challenge, signature: await an.sign(challenge, kp) }
}
// ⛔ Try the key we already hold, first and silently. A registered browser must not be asked for
// an invite it cannot obtain — that is a lockout, and it is what happens on every surface restart
// because sessions live in memory.
//
// Only runs when a keypair exists; a first visit has none and falls through to claim or redeem.
;(async () => {
if (!localStorage.getItem(KEY)) return
try {
const res = await fetch('/auth/login', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(await answer())
})
if (res.ok) location.href = '/'
else msg.textContent = 'This browser is not accepted here.'
} catch {}
})()
document.getElementById('f').onsubmit = async (e) => {
e.preventDefault()
msg.textContent = 'signing…'
try {
// No token field means this is the TOFU claim — a fresh instance nobody holds yet.
const el = document.getElementById('token')
const body = await answer()
if (el) body.token = el.value.trim()
const res = await fetch(el ? '/auth/redeem' : '/auth/claim', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body)
})
if (!res.ok) throw new Error((await res.json()).error || 'refused')
location.href = '/'
} catch (err) { msg.textContent = err.message }
}
`
/**
* A bio squeezed onto one line for a listing.
*
* ⚠ It is MARKDOWN, and this does not render it — a list wants a sentence, not a heading and a
* bullet list. Rendering happens on the profile page where there is room for it.
*/
function oneLine (text, max) {
const flat = String(text || '').replace(/\s+/g, ' ').trim()
return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
}
/**
* ⛔ The nav marks where you ARE. Eight identical underlined links told a reader nothing about which
* page they were on, which is most of what a navigation bar is for.
*/
// ⛔ `discover` SITS BESIDE `search`, and the pair is deliberate. Search answers "where is this
// person I can name"; discover answers "who is here at all" — and until it existed only the first
// question could be asked, so joining a namespace and then finding nobody was the whole experience.
const NAV_ITEMS = [['/', 'timeline'], ['/rooms', 'rooms'], ['/search', 'search'], ['/discover', 'discover'],
['/following', 'following'],
['/notifications', 'notifications'], ['/mentions', 'mentions'], ['/knocks', 'knocks'],
['/graph', 'graph'], ['/explore', 'explore'], ['/status', 'status'],
['/settings', 'settings'], ['/logout', 'sign out']]
const NAV = (title) => '<nav>' + NAV_ITEMS.map(([href, label]) =>
'<a href="' + href + '"' + (label === title || (title === 'favela' && href === '/') ? ' aria-current="page"' : '') +
'>' + label + '</a>').join('') + '</nav>'
export function layout (title, body) {
return '<!doctype html><html lang="en"><head><meta charset="utf-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<title>' + esc(title) + '</title><style>' + CSS + '</style></head><body>' +
NAV(title) + '<main>' + body + '</main></body></html>'
}
/**
* How `render` turns `favela://blob/<oid>` into something showable.
*
* ⛔ The library has no opinion about URLs — it asks, and this answers. `/blob/<oid>` is a path on
* THIS surface, which streams the file from the daemon's disk; the bytes got to this machine over
* the peer's own P2P replication. Nothing about a file crosses a network as HTTP.
*
* `files` is oid -> mimeType, resolved before rendering because `render` is synchronous and a type
* comes from the daemon. An oid absent from it renders as nothing, which is right: we could not
* establish what it is, and guessing is how a text file becomes an <img>.
*/
export const blobResolver = (files) => ({
resolveBlob: (oid) => {
const mimeType = files && files[oid]
return mimeType ? { src: '/blob/' + oid, mimeType } : null
}
})
// One record. `render()` produces the body HTML; everything else is escaped here.
/**
* One record.
*
* ⛔ The like button renders its STATE. It used to be a bare `like` on every post whether you had
* liked it or not, with no way back — so a reader could not tell a liked post from an unliked one,
* and pressing it twice looked exactly like pressing it once. `row.liked` comes from the daemon,
* computed from one listing of your own feed for the whole page.
*
* ⚠ No COUNT, deliberately. `peer.js` refuses to produce one: you may count what you can SEE, and
* a number rendered here would be a local artifact that reads as a global total.
*/
export function post (row, profiles, files = null, from = '/') {
const when = esc((row.createdAt || '').slice(0, 16).replace('T', ' '))
const byline = who(row.author, profiles, { avatar: false })
const target = encodeURIComponent(row.author) + '/' + encodeURIComponent(row.collection) + '/' + encodeURIComponent(row.name)
/**
* ⛔ Every post is an ANCHOR, and every action carries it.
*
* A no-JS action is a form POST and a redirect, so the browser loads the page again and lands at
* the top — liking the twentieth post threw the reader back to the first. A fragment on the
* redirect is the whole fix, and it needs no script: the browser scrolls to the post you acted on.
*/
const anchor = 'p-' + (row.author + '-' + row.collection + '-' + row.name).replace(/[^A-Za-z0-9_-]/g, '')
const back = from + '#' + anchor
const liked = row.liked === true
const mine = new Set(Array.isArray(row.reactions) ? row.reactions : [])
const button = (value, label, on) =>
'<form method="post" action="' + (on ? '/unreact' : '/react') + '">' +
'<input type="hidden" name="target" value="' + esc(target) + '">' +
'<input type="hidden" name="from" value="' + esc(back) + '">' +
'<input type="hidden" name="value" value="' + esc(value) + '">' +
'<button class="' + (on ? 'liked' : '') + '">' + label + '</button></form>'
// ⛔ Like is offered as itself, not as one emoji among several. It is the one value both target
// networks carry, and burying it in a palette would make the common act the same weight as a
// reaction only keet and Mastodon can express.
const likeBtn = button('like', liked ? '♥ liked' : '♡ like', liked)
// ⚠ Only reactions I ALREADY hold are shown beside it. Everyone else's need a scan across held
// feeds, which is per-target work and belongs on a post's own page, not on every timeline row.
/**
* ⚠ Held reactions and the palette live in ONE group. Rendering the ones you hold beside the
* ACTS and the rest pushed right split the emoji across the row, so a 🔥 you had already added
* looked orphaned in the middle of the controls rather than switched on.
*
* Order: what is on, then what is on offer. So adding one does not make it jump.
*/
const emoji = '<span class="quick">' +
[...mine].filter((v) => v !== 'like').map((v) => button(v, esc(v), true)).join('') +
QUICK.filter((v) => !mine.has(v)).map((v) => button(v, v, false)).join('') +
'</span>'
const reposted = row.reposted === true
const repostBtn = '<form method="post" action="' + (reposted ? '/unrepost' : '/repost') + '">' +
'<input type="hidden" name="target" value="' + esc(target) + '">' +
'<input type="hidden" name="from" value="' + esc(back) + '">' +
'<button class="' + (reposted ? 'liked' : '') + '">' + (reposted ? '⇌ reposted' : '⇄ repost') +
'</button></form>'
// ⛔ QUOTE is not a button that acts — it is a link to the COMPOSER, prefilled. `record-vocabulary`
// §3.5: "quotes are just links", so a quote is an ordinary post whose body references the quoted
// record. There is no quote record to create, and pretending otherwise here would invent one.
// ⛔ quote and thread NAVIGATE. Styling them as buttons said they act on the post, which they do
// not — one seeds the composer and the other opens a page.
const quoteBtn = '<a class="go" href="/?quote=' + encodeURIComponent(target) + '">quote</a>'
// Every post is a way into its own conversation. The thread page is where replies live, because a
// timeline that inlined them would stop being a timeline.
const threadBtn = '<a class="go" href="/t/' + encodeURIComponent(target) + '">thread</a>'
// ⛔ MY posts and replies only (the daemon marks `own`), and a LINK to a page that asks first: the
// delete reaches bsky and the fediverse, so it is never one press beside like.
const deleteBtn = row.own === true && (row.collection === 'post' || row.collection === 'reply')
? '<a class="go" href="/delete?target=' + encodeURIComponent(target) + '&from=' + encodeURIComponent(from) + '">delete</a>'
: ''
/**
* ⛔ TWO COLUMNS — avatar in a gutter, everything else beside it. Every mature client in this
* shape does it (bsky, mastodon, twitter) and the reason is structural rather than fashionable: a
* post has one author and many lines, so the identity belongs in a fixed column and the content
* gets a single consistent left edge. Inline, a 20px avatar pushes the first line of every post
* to a different indent from the rest of it.
*/
return '<article id="' + esc(anchor) + '">' +
'<div class="av-col">' + avatarOf(row.author, profiles) + '</div>' +
'<div class="post-col">' +
'<div class="meta">' + byline + '<span class="sep">·</span><time>' + when + '</time></div>' +
render(row.body || '', blobResolver(files)) +
'<div class="reactions">' + likeBtn + repostBtn + quoteBtn + threadBtn + deleteBtn + emoji +
'</div></div></article>'
}
// The gutter avatar. ⚠ Separate from `who()`, which renders an INLINE one for lists and bylines —
// two sizes doing two jobs rather than one stretched to cover both.
function avatarOf (author, profiles) {
const p = (profiles && profiles[author]) || null
return p && p.avatar
? '<a href="/u/' + esc(author) + '"><img class="av-lg-sm" src="/avatar/' + esc(p.avatar) + '" alt=""></a>'
: '<a href="/u/' + esc(author) + '"><span class="av-blank"></span></a>'
}
/**
* The @-picker. Vendored and page-local, like the login script — the only other JS this surface
* ships (`static/README.md` explains why nothing is taken as a dependency here).
*
* ⛔ PROGRESSIVE. It inserts `@alice`, which is ordinary text a person could have typed, and the
* daemon resolves it on the write path either way. With JS off the composer still works; with it on
* the picker cannot produce a mention the server would not have produced anyway. It is an
* accelerant, never an authority.
*/
// A small fixed palette. ⚠ Not a picker: an arbitrary-value field is a text channel next to every
// post, and the daemon bounds the value for the same reason.
const QUICK = ['🔥', '😂', '👀']
export function timeline (rows, { unreadable = 0, profiles = null, files = null, message = null, quote = null, mention = null, empty: state = null } = {}) {
const warn = unreadable
// ⛔ Never silent. "nothing here" and "nothing here, and N records I hold pointers for but
// cannot read" are different answers, and the daemon distinguishes them — so the page must too.
? '<p class="warn">' + unreadable + ' record(s) could not be read — the log arrived, the bytes did not. ' +
'<a href="/status">what this means</a></p>'
: ''
/**
* ⛔ TWO KINDS OF EMPTY, and they are not the same problem.
*
* follows 0 there is nothing to read because nothing was asked for. The fix is to find
* somebody, and the page says where.
* follows N N feeds were asked for and none of them has arrived yet. The fix is time, or a
* look at which ones are missing — and telling this reader to "follow somebody"
* would be advice they have already taken.
*
* Conflating them is the UI version of drawing a converged edge and a resolved one the same way.
*/
const nothing = !state
? empty('Nothing yet.', null, [])
: (state.follows === 0
? empty('Your timeline is empty.',
'A timeline is made of the feeds you hold, and you hold none yet. Find somebody, or ' +
'paste a feed key if you already have one.',
[{ href: '/search', label: 'search for people' }, { href: '/following', label: 'paste a key' }])
: empty('Nothing has arrived yet.',
'You follow ' + state.follows + ' feed(s). None of them has anything this node can read ' +
'so far — replication takes as long as it takes, and a feed that never arrives shows as ' +
'not replicated in your list.',
[{ href: '/following', label: 'see who you follow' }]))
const body = rows.length ? rows.map((r) => post(r, profiles, files, '/')).join('') : nothing
return layout('favela', composer(message, quote, mention) + warn + body)
}
export function composer (message, quote = null, mention = null) {
const note = message ? '<p class="warn">' + esc(message) + '</p>' : ''
/**
* ⛔ A quote is PREFILLED TEXT, not a field. `record-vocabulary.md` §3.5 — "quotes are just
* links" — so quoting produces an ordinary post whose body references the quoted record. There is
* no quote record and no quote pointer; inventing either here would create a second thing the
* whole stack would then have to carry.
*
* ⚠ The author can edit or delete the line before posting, which is the point: it is their text.
*/
// ⚠ A mention seeds a real markdown LINK, because that is what a mention IS. The author can edit
// the label or delete the line; what they cannot do is produce one by typing a bare @name, and
// pretending otherwise is what `markdown-strip.md` §3.4 refuses.
const seed = quote ? '> ' + quote + '\n\n' : (mention ? mention + ' ' : '')
const hint = quote
? '<p class="who">Quoting — the link is ordinary text in your post, and you can change it.</p>'
: (mention ? '<p class="who">The mention is a link in your text. A bare @name is not a mention ' +
'— it would be a guess about who you meant.</p>' : '')
// ⛔ multipart, because it carries a file. The file is stored FIRST and its oid appended to the
// body as `` — so what is published is an ordinary markdown record and
// nothing about attachments is special-cased downstream.
// ⛔ The composer is not the page. A tall box, a permanent file input and two lines of help that
// never changed took a third of the first screen before any content appeared. The help still
// EXISTS — it says SVG is refused and that the type is sniffed, which people need before they
// choose a file — it just stops shouting.
return note + '<form class="composer" method="post" action="/post" enctype="multipart/form-data">' +
'<textarea name="body" rows="2" placeholder="write markdown…">' + esc(seed) + '</textarea>' + hint +
'<div class="row">' +
'<input type="file" name="file">' +
'<button class="post">post</button>' +
'</div>' +
// ⚠ The list lives INSIDE the composer, so it is positioned against it and disappears with it.
'<ul id="at" hidden></ul>' +
'<details><summary>attachments</summary>' +
'Images, video, PDF or text. The type is read from the file itself, never its name, and SVG is ' +
'refused.</details>' +
'</form>' +
// ⛔ The picker script. It must be EMITTED — the first version defined MENTION_JS and never put
// it on the page, so the list element existed, the endpoint answered, and nothing ever ran.
'<script type="module" src="/static/composer.js"></script>'
}
export function search (query, results, unreadable, people = [], profiles = null, files = null, handles = []) {
const form = '<form method="get" action="/search">' +
'<input type="text" name="q" value="' + esc(query || '') + '" placeholder="search people and posts">' +
'<button>search</button></form>'
const warn = unreadable ? '<p class="warn">' + unreadable + ' record(s) in scope could not be read.</p>' : ''
// ⛔ People and posts are shown APART. They are different answers to one query — someone typing
// "stortron" may want the person or the mentions — and interleaving them would force the reader
// to sort out which is which.
const found = people.length
? '<h2>People</h2><ul>' + people.map((x) =>
// ⛔ Merged with `profiles` so a person found by their self-label still renders their HANDLE.
// A search result that showed a different name from the timeline would teach the reader that
// the two disagree, which they do not.
'<li>' + who(x.author, { [x.author]: { ...x, ...((profiles && profiles[x.author]) || {}) } }) +
'</li>').join('') + '</ul>'
: ''
// ⚠ Liking from a search used to throw the search away — `/like` always redirected to `/`. The
// page tells the route where it came from.
const back = '/search?q=' + encodeURIComponent(query || '')
/**
* Handles from the shared namespace.
*
* ⛔ The gap this closes: `search` above looks at records and profiles of feeds you HOLD. A peer
* you have never followed has neither, so a fleet sharing a namespace could not find itself —
* every handle was resolvable and nothing asked. This is the one result kind that can name
* somebody you have no copy of, which is exactly what discovery has to do.
*
* ⛔ MISMATCH is dropped, never annotated. A name claimed at someone else's feed key is the
* attack, and a row a person can press "follow" on is the worst possible place to render it.
* ⚠ `unheld` IS shown, labelled: we cannot check a feed we do not hold, and refusing to show it
* would make the only feeds findable the ones you already have.
*/
const nameRows = handles.filter((h) => h.verdict !== 'MISMATCH')
const names = !nameRows.length ? '' : '<h2>Handles</h2><ul>' + nameRows.map((h) =>
// ⚠ The handle LINKS to their page. From there you can look without following — search should
// not force a follow just to see who somebody is.
'<li><a href="/u/' + esc(h.feed) + '"><b>@' + esc(h.handle) + '</b></a>' +
(h.verdict === 'confirmed' ? '' : ' <span class="who">[' + esc(h.verdict) + ']</span>') +
// ⚠ A confirmed handle already names them; the key is what you check them AGAINST, which is a
// second look rather than the first thing on the row.
keyline('feed key', h.feed) +
'<form method="post" action="/follow">' +
'<input type="hidden" name="feed" value="' + esc(h.feed) + '">' +
'<button>follow</button></form></li>').join('') + '</ul>'
const hits = !query ? '' : '<h2>Posts</h2>' +
(results.length ? results.map((r) => post(r, profiles, files, back)).join('') : '<p>No matches.</p>')
return layout('search', form + warn + names + found + hits)
}
/**
* Posts that NAME me.
*
* ⛔ Only from feeds this node holds. Someone you have no copy of can name you and you will never
* know — the structural limit of writing every interaction in its author's own feed. The page says
* so rather than letting an empty list imply nobody has mentioned you.
*/
export function mentions (rows, profiles = null, files = null) {
const body = rows.length
? rows.map((r) => post(r, profiles, files, '/mentions')).join('')
: empty('Nobody you hold has named you.',
'And only a [@name](favela://…) link counts — a bare @name is plain text, never a guess ' +
'about who was meant.',
[{ href: '/following', label: 'see who you follow' }])
const note = rows.length
? '<p class="who">Only posts from feeds you hold are searched, and only a ' +
'<code>[@name](favela://…)</code> link counts — a bare <code>@name</code> is plain text, ' +
'never a guess about who was meant.</p>'
: ''
return layout('mentions', body + note)
}
/**
* One conversation: the subject, then its replies, then a box to add one.
*
* ⛔ `subject` may be null — we hold a reply pointing at a post we do not have. That is a different
* fact from "no replies yet" and the page says which, rather than rendering an unread subject as an
* empty conversation.
*
* ⚠ Replies are rendered in the order given. `peer.repliesTo` sorts them oldest-first because a
* thread reads FORWARDS where a timeline reads backwards; re-sorting here would undo that.
*/
export function thread (subject, replies, profiles = null, files = null, target = '') {
const head = subject
? post(subject, profiles, files, '/t/' + encodeURIComponent(target))
: '<p class="warn">The post being replied to cannot be read — you may not hold that feed.</p>'
const body = replies.length
? replies.map((r) => post(r, profiles, files, '/t/' + encodeURIComponent(target))).join('')
: '<p class="who">No replies yet.</p>'
const form = '<form method="post" action="/reply">' +
'<input type="hidden" name="target" value="' + esc(target) + '">' +
'<textarea name="body" rows="3" placeholder="reply…"></textarea>' +
'<button>reply</button></form>'
return layout('thread', head + '<h2>Replies</h2>' + body + form)
}
/**
* What other people did that concerns you.
*
* ⛔ Derived on read from records you already hold — nothing was delivered to produce it. The page
* says so, because "no notifications" here means something different from an empty inbox elsewhere:
* only feeds you HOLD can appear, so a stranger interacting with you is invisible. The doorbell is
* the exception and it is in the list.
*/
// ⛔ AN OUTSIDE ACTOR IS NOT A FEED (hyper-social #82, I1). A row from a bridge's observation feed names an account on
// another network by its URI. who() would link /u/<that URI> — a favela profile that does not exist. Shown as the
// account it is (user@host when the URI says so), linking OUT, and marked as coming from the fediverse. Only an
// http(s) URI ever becomes a link.
function outsideActor (uri) {
let url = null
try { url = new URL(String(uri)) } catch {}
if (!url || (url.protocol !== 'https:' && url.protocol !== 'http:')) return '<span class="who">' + esc(String(uri || 'someone')) + '</span> <span class="who">(fediverse)</span>'
const seg = url.pathname.split('/').filter(Boolean)
const user = seg.length >= 2 && (seg[0] === 'users' || seg[0] === 'u') ? seg[1] : (seg[0] && seg[0].startsWith('@') ? seg[0].slice(1) : null)
const label = user ? user + '@' + url.host : url.host + url.pathname
return '<a class="who" href="' + esc(url.href) + '" rel="nofollow noopener" target="_blank">' + esc(label) + '</a> <span class="who">(fediverse)</span>'
}
// A bsky actor is a DID (hyper-social #82, I2). Linked to its bsky.app profile — only a well-formed did: becomes a link.
function bskyActor (did) {
const d = String(did || '')
if (!/^did:[a-z]+:[A-Za-z0-9._:%-]+$/.test(d)) return '<span class="who">' + esc(d || 'someone') + '</span> <span class="who">(bluesky)</span>'
const short = d.length > 24 ? d.slice(0, 20) + '…' : d
return '<a class="who" href="https://bsky.app/profile/' + esc(d) + '" rel="nofollow noopener" target="_blank">' + esc(short) + '</a> <span class="who">(bluesky)</span>'
}
export function notifications (rows, profiles = null) {
const VERB = {
reaction: (n) => (n.value === 'like' ? 'liked' : 'reacted ' + esc(n.value || '')) + ' your post',
repost: () => 'reposted your post',
reply: () => 'replied to your post',
mention: () => 'mentioned you',
follow: () => 'followed you',
knock: () => 'rang your doorbell'
}
const items = rows.length
? '<ul class="people">' + rows.map((n) => {
const verb = (VERB[n.kind] || (() => n.kind))(n)
const link = n.target
? ' <a href="/t/' + encodeURIComponent(n.target.feed + '/' + n.target.collection + '/' + n.target.name) + '">→</a>'
: ''
const snippet = n.body ? '<div class="who bio">' + esc(n.body.replace(/\s+/g, ' ').trim().slice(0, 120)) + '</div>' : ''
const by = n.source === 'fediverse' ? outsideActor(n.author) : n.source === 'bsky' ? bskyActor(n.author) : who(n.author, profiles)
return '<li>' + by + ' ' + verb + link + snippet + '</li>'
}).join('') + '</ul>'
: empty('No notifications.',
'These are derived from feeds you already hold — nothing is sent to you. Somebody whose feed ' +
'you do not hold can interact with you and this will not show it; ringing your doorbell is ' +
'the one thing they can always do.',
[{ href: '/knocks', label: 'see who knocked' }, { href: '/following', label: 'see who you follow' }])
const note = rows.length
? '<p class="who">Derived from feeds you already hold — nothing is sent to you. Someone whose ' +
'feed you do not hold can interact with you and this will not show it; ringing your doorbell ' +
'is the one thing they can always do.</p>'
: ''
return layout('notifications', items + note)
}
/**
* The rooms this node is in.
*
* ⛔ A room key is a CAPABILITY — holding it is what lets you replicate the room at all — so this
* page shows the key and offers a box to paste one. There is no directory to browse, and that is
* the model rather than a gap: a room is reached because somebody handed you the way in.
*/
/**
* The graph — the substrate, drawn.
*
* ⛔ Inline SVG with a DETERMINISTIC layout and no script. A force simulation would need JS, and
* would also move things between reloads, which for a diagnostic is worse than ugly: you could not
* tell a changed graph from a re-run.
*
* ⛔ The STATE is on the edge, which is the whole point (EDGE-GRAPH §4). Solid means we hold it and
* it converged; dashed means converged but the bytes are not readable; the legend says which
* failure each one is, because "a dashed edge at ② is a quorum problem and at ③ a byte problem" is
* the distinction the view exists to make.
*/
export function graph (g) {
const W = 640
const actors = g.nodes.filter((n) => n.type === 'actor')
const records = g.nodes.filter((n) => n.type === 'record')
const H = Math.max(280, 120 + records.length * 26)
// Actors down the left, records down the right. ⚠ Deterministic: same input, same picture.
const pos = new Map()
actors.forEach((n, i) => pos.set(n.id, { x: 90, y: 60 + i * Math.max(40, (H - 120) / Math.max(1, actors.length - 1 || 1)) }))
records.forEach((n, i) => pos.set(n.id, { x: W - 190, y: 50 + i * 26 }))
const short = (id) => esc(id.length > 20 ? id.slice(0, 8) + '…' : id)
/**
* ⛔ A PAIR OF EDGES MUST LOOK LIKE TWO. A→B and B→A between the same points draw on top of each
* other and become one line — which hides precisely the failure EDGE-GRAPH §4.1 says the view
* exists to catch: "the common bug is one direction landing and the other not". Each direction is
* bowed to its own side, so a pair reads as two and a lone direction reads as one.
*/
const bow = (a, b, back) => {
const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2
const dx = b.x - a.x; const dy = b.y - a.y
const len = Math.hypot(dx, dy) || 1
const k = (back ? -1 : 1) * 14
return 'M' + a.x + ' ' + a.y + ' Q' + (mx - (dy / len) * k) + ' ' + (my + (dx / len) * k) +
' ' + b.x + ' ' + b.y
}
const seen = new Set()
const lines = g.edges.map((e) => {
const a = pos.get(e.from); const b = pos.get(e.to)
if (!a || !b) return ''
// The second direction of a pair bows the other way.
const back = seen.has(e.to + '|' + e.from)
seen.add(e.from + '|' + e.to)
const target = g.nodes.find((n) => n.id === e.to)
// ⛔ The SUBJECT's state decides the line: an edge pointing at a record whose bytes we cannot
// read is the byte-availability failure, and it must not look like the ones that worked.
const unresolved = target && target.state === 'converged'
return '<path d="' + bow(a, b, back) + '" fill="none"' +
' class="e ' + esc(e.kind) + (unresolved ? ' gap' : '') + '"><title>' +
esc(e.kind + (e.value ? ' ' + e.value : '') + ' — ' + (unresolved ? 'bytes NOT readable' : 'converged')) +
'</title></path>'
}).join('')
const dots = [...pos].map(([id, p]) => {
const n = g.nodes.find((x) => x.id === id)
const cls = n.type === 'actor' ? 'a' : (n.state === 'converged' ? 'r gap' : 'r')
return '<circle cx="' + p.x + '" cy="' + p.y + '" r="' + (n.type === 'actor' ? 7 : 4) + '" class="' + cls + '">' +
'<title>' + esc(id) + '</title></circle>' +
(n.type === 'actor'
? '<text x="' + (p.x - 12) + '" y="' + (p.y + 4) + '" text-anchor="end">' + short(id) + '</text>'
// ⚠ Records are labelled by their NAME, not their key. The key is the actor's and repeating
// it on every record of theirs is noise that hides the one part that differs.
: '<text x="' + (p.x + 10) + '" y="' + (p.y + 4) + '">' + esc(id.split('/').slice(1).join('/')) + '</text>')
}).join('')
return layout('graph',
'<h1>Graph</h1>' +
'<p class="who">' + esc(String(g.nodes.length)) + ' nodes, ' + esc(String(g.edges.length)) +
' edges — everything this node holds. Not the network: a graph of one node\'s view is the only ' +
'honest one.</p>' +
'<svg viewBox="0 0 ' + W + ' ' + H + '" class="graph">' + lines + dots + '</svg>' +
'<ul class="legend">' +
'<li><span class="k solid"></span> converged — we hold it and it linearized</li>' +
'<li><span class="k dash"></span> bytes NOT readable — the pointer arrived, the file did not</li>' +
'<li class="who">projection to other networks: <b>' + esc(g.projection) + '</b> — no bridge is ' +
'wired to this daemon, and a state we cannot observe is not drawn as one we can</li>' +
'</ul>')
}
/**
* The discovery view — a separate PAGE, on purpose.
*
* ⛔ EDGE-GRAPH §5: the debug and discovery renderings have opposite duties and "must not be one
* screen". This one rotates, which is useful for exploring and useless for comparing; the debug
* view stays deterministic so a diagnosis can be screenshotted.
*
* ⛔ It never names a cluster, and there is no export. COMMUNITY-EMERGENCE: "the observation is an
* intervention" — surfacing a cluster is allowed, naming it is a human act. And §6.4: the
* aggregation attack's mitigation is "no mechanism, not a permission check".
*/
export function explore () {
return layout('explore',
'<h1>Explore</h1>' +
'<p class="who">Force-directed, and the third axis is TIME — a cluster forming reads as a ' +
'widening shape. Drag to rotate, scroll to zoom. This is a picture for looking at, not for ' +
'comparing: the <a href="/graph">graph</a> is the one that holds still.</p>' +
'<div class="explorer">' +
'<div class="scene-bar"><div class="legend">' +
// ⛔ The legend is SERVER-RENDERED and needs no JavaScript. It is the direct answer to "I have
// no idea what the points are", and an answer that arrives only once a megabyte of WebGL has
// loaded is not one.
'<span><i class="actor"></i> a person</span>' +
'<span><i class="record"></i> a post</span>' +
'<span><i class="gap"></i> held, bytes never arrived</span>' +
'<span><i class="e e-reaction"></i> a reaction</span>' +
'<span><i class="e e-repost"></i> a repost</span>' +
'<span><i class="e e-reply"></i> a reply</span>' +
'<span><i class="e e-follow"></i> a follow</span>' +
// ⚠ Authorship is drawn faint because it is always true and would otherwise bury every social
// edge. Saying so is the difference between a quiet line and one you assume is a failed link.
'<span><i class="weak"></i> who wrote it</span>' +
'</div><button type="button" id="fullscreen">fullscreen</button></div>' +
'<div id="scene" class="scene"><div class="tip" id="tip" hidden></div></div>' +
'<dl class="stats" id="stats"></dl>' +
'</div>' +
'<p class="who">⛔ It does not name what it finds. Surfacing a cluster is one thing; naming it ' +
'is a political act and belongs to a person. There is no export, deliberately — a view that ' +
'combines several peers is the aggregation this model exists to prevent.</p>' +
'<p class="who">Hover a point to see what it is; click one to open it on a plane in the scene. ' +
'Scroll in among the points and the nearest posts open by themselves. ' +
'⚠ Needs JavaScript and ships ~730K of three.js, vendored. Everything else here works without ' +
'either.</p>' +
'<script type="module" src="/static/explore.js"></script>')
}
export function rooms (list) {
const items = list.length
? '<ul class="people">' + list.map((r) =>
'<li><a href="/r/' + esc(r.key) + '"><b>' + esc(r.config.title || 'untitled room') + '</b></a>' +
'<div class="who">' + esc(String(r.members)) + ' member(s) · <code>' + esc(r.key.slice(0, 16)) + '…</code></div>' +
'</li>').join('') + '</ul>'
: empty('No rooms yet.',
'A room is a place with its own members, and what you write in one is read by them. Make one, ' +
'or paste a key somebody handed you into the box above.',
[{ post: '/rooms/create', label: 'make a room' }])
return layout('rooms',
'<form class="inline-form" method="post" action="/rooms/join">' +
'<input type="text" name="key" placeholder="room key (64 hex)"><button>join</button></form>' +
'<form method="post" action="/rooms/create"><button>make a room</button></form>' +
items)
}
/**
* One room.
*
* ⛔ Three states, and the middle one is the whole reason rooms are not feeds: you can be IN a room
* (holding its key, replicating, reading) without being OF it (a member, who may write). Rendering
* a composer to somebody whose posts apply would silently drop is the "it returned, so it worked"
* failure this stack refuses everywhere else.
*/
export function room (key, r, profiles = null, { message = null, invitation = null } = {}) {
const role = r.role
// An older daemon does not report `bound`; only an explicit false means this device cannot write.
const unbound = r.bound === false
const steward = role === 'admin' || role === 'moderator'
const title = r.config.title || 'untitled room'
const who_ = (root) => {
const p = profiles && Object.values(profiles).find((x) => x && x.root === root)
return p ? esc(p.handle ? '@' + p.handle : (p.name || root.slice(0, 12))) : '<code>' + esc(root.slice(0, 12)) + '…</code>'
}
// ⚠ Not the timeline's two-column article. A room message has no avatar gutter to fill, and an
// empty column indents every line for nothing.
const msgs = r.messages.length
? '<div class="said">' + r.messages.map((m) =>
'<div class="say"><div class="meta">' + who_(m.author) + '</div>' +
render(m.body || '') + '</div>').join('') + '</div>'
: '<p class="who">Nothing said yet.</p>'
// ⛔ ROOM-CONTRACT v2: a member's other device is a member with no bound core. A post from it would
// be carried by a core apply does not know, and dropped — so it is offered the bind, not a composer.
const compose = role && unbound
? '<form method="post" action="/r/' + esc(key) + '/add-device"><button>use this device here</button></form>' +
'<p class="who">You are a member of this room on another device. This machine joins as one more ' +
'of yours, carrying its own writer core.</p>'
: role && role !== 'read-only'
? '<form method="post" action="/r/' + esc(key) + '/post" class="composer">' +
'<textarea name="body" rows="2" placeholder="say something…"></textarea>' +
'<div class="row"><span class="who">posting as ' + esc(role) + '</span>' +
'<button class="post">send</button></div></form>'
: (role === 'read-only'
? '<p class="who">You are a read-only member here.</p>'
: '<form class="inline-form" method="post" action="/r/' + esc(key) + '/join">' +
'<input type="text" name="token" placeholder="invitation (hex)"><button>join</button></form>' +
'<p class="who">You hold this room and can read it. Writing in it takes an invitation from ' +
'somebody inside — a new member cannot let themselves in.</p>')
// Requests and invitations: what a moderator or admin acts on, so only they see them.
const asks = steward && r.requests && r.requests.length
? '<h2>Asking to join</h2><ul class="people">' + r.requests.map((q) =>
'<li class="ask">' + who_(q.root) +
'<form method="post" action="/r/' + esc(key) + '/accept">' +
'<input type="hidden" name="writer" value="' + esc(q.writer) + '"><button>let them in</button></form>' +
'<form method="post" action="/r/' + esc(key) + '/reject">' +
'<input type="hidden" name="writer" value="' + esc(q.writer) + '"><button>turn down</button></form>' +
'</li>').join('') + '</ul>'
: ''
// ⛔ Shown once, in the response that created it. It is the invitation's signing secret: whoever
// holds it can join until it is revoked, so it goes to one person over a private channel.
const minted = invitation
? '<div class="warn"><p>Invitation — hand this to one person, privately:</p>' +
keyline('invitation', invitation.token) + '</div>'
: ''
const inviteForm = steward
? '<form class="inline-form" method="post" action="/r/' + esc(key) + '/invite">' +
'<select name="role"><option value="peer">peer</option>' +
(role === 'admin' ? '<option value="moderator">moderator</option>' : '') + '</select>' +
'<label><input type="checkbox" name="confirm"> ask me first</label>' +
'<button>make an invitation</button></form>'
: ''
const members = '<h2>Members</h2><ul class="people">' + r.members.map((m) =>
'<li>' + who_(m.root) + ' <span class="flag">' + esc(m.role) + '</span></li>').join('') + '</ul>'
// ⚠ The sentence stays on screen and the key folds under it: what the key DOES is the part a
// reader needs, and it is also the warning.
return layout('rooms', '<h1>' + esc(title) + '</h1>' +
(message ? '<p class="warn">' + esc(message) + '</p>' : '') +
'<p class="who">Anyone with this key can read the room. Writing in it takes an invitation.</p>' +
keyline('room key', key) +
minted + compose + inviteForm + asks + msgs + members)
}
export function following (keys, missing, profiles = null) {
const gap = new Set(missing || [])
const items = keys.length
? '<ul class="people">' + keys.map((k) => {
const p = (profiles && profiles[k]) || null
// ⛔ A followed feed we cannot read is NAMED, not silently absent from the list — and it is
// also why the bio may be missing here while the key is not.
// ⚠ An INLINE marker, not the block `.warn`. Styling a two-word note as a filled alert box
// made a normal state — a feed you follow that has not arrived — shout louder than the person
// it was attached to.
const warn = gap.has(k) ? ' <span class="flag">not replicated</span>' : ''
// The same byline the timeline uses: picture, handle, self-label, key. A list of people that
// rendered differently from every other place would teach the reader the two disagree.
const bio = p && p.bio
? '<div class="who bio">' + esc(oneLine(p.bio, 140)) + '</div>'
: ''
return '<li>' + who(k, profiles) + warn + bio + '</li>'
}).join('') + '</ul>'
// ⚠ The follow form is already below; the empty state points AT it rather than repeating it.
: empty('Following nobody.',
'Everything you read comes from a feed you hold. Search for a person, or paste a feed key ' +
'into the box below.',
[{ href: '/search', label: 'search for people' }, { href: '/knocks', label: 'see who knocked' }])
const form = '<form class="inline-form" method="post" action="/follow">' +
'<input type="text" name="feed" placeholder="feed key (64 hex)"><button>follow</button></form>'
return layout('following', form + items)
}
/**
* The settings page — a display name and a picture.
*
* ⛔ These are properties of the ACCOUNT, not of anything published. A name must never appear in a
* record (NAMING.md §2): names are reclaimable, so one baked into immutable bytes can later denote
* a different person. They live on the user row, which is the thing allowed to change.
*
* No JavaScript. A file input in a plain form is `multipart/form-data`, which the server parses —
* the login page is the only place a script is unavoidable, because signing needs one.
*/
/**
* Someone's profile page.
*
* ⛔ Shows the FULL key, not the eight-character prefix. A prefix is fine for scanning a timeline
* and useless for deciding whether two feeds are the same person — which is exactly what you came
* to this page to do.
*/
export function person (author, profile, posts, files = null, holding = null) {
const p = profile || {}
const pic = p.avatar
? '<img class="av-lg" src="/avatar/' + esc(p.avatar) + '" alt="">'
: ''
// ⛔ The HANDLE is the heading when there is one — it is the only one of the three names that is
// unique anywhere, and the only one anybody had to prove.
const name = p.handle
? '<h1>@' + esc(p.handle) + '</h1>'
: (p.name ? '<h1>' + esc(p.name) + '</h1>' : '<h1 class="who">no published name</h1>')
// ⚠ Two different KINDS of name, so the page says which is which rather than stacking them.
// A self-label is a CLAIM: nothing enforces uniqueness and two feeds may carry the same one.
// A handle is claimed in a namespace, unique within it, and attested to this feed's owner.
const claim = p.handle
? '<p class="who">' + (p.name ? 'calls itself ' + esc(p.name) + ' · ' : '') +
'the handle is claimed in a namespace and confirmed against this feed\'s owner' + '</p>'
: (p.name ? '<p class="who">a name this feed chose for itself — not a unique handle</p>' : '')
/**
* ⛔ Three states, three different offers, and conflating any two of them lies to the reader:
*
* not held we have no copy of this feed — the page is EMPTY because of that, not because
* they never posted. Offer to take a look.
* peeked held to read, no follow record anywhere. Offer to follow.
* following nothing to offer.
*
* ⚠ "Nothing from them yet" on a feed we do not hold would be a lie, and it is the same
* silent-absence failure the daemon refuses at every other layer.
*/
const h = holding || {}
// ⚠ Still arriving. Rendering this as "not held" would offer a look that is already happening,
// and rendering it as held would claim they posted nothing when their feed simply has not landed.
// The page reloads itself once rather than polling — a feed either arrives quickly or the reader
// can press again.
const act = h.opening
? '<p class="who">Fetching their feed… <a href="/u/' + esc(author) + '">reload</a></p>' +
'<meta http-equiv="refresh" content="3">'
: !h.held
? '<p class="who">You do not hold this feed, so there is nothing of theirs here yet — that is ' +
'not the same as them having posted nothing.</p>' +
'<form method="post" action="/peek">' +
'<input type="hidden" name="feed" value="' + esc(author) + '">' +
'<button>take a look</button></form>' +
'<p class="who">Copies their feed to this machine and joins their discovery topic, so they ' +
'can see a peer arrived. It lasts until this daemon restarts.</p>'
: (h.peeked
? '<p class="who">You are reading this without following. Their posts are not in your timeline.</p>' +
'<form method="post" action="/follow">' +
'<input type="hidden" name="feed" value="' + esc(author) + '">' +
'<button>follow</button></form>'
: '')
const body = posts.length
? posts.map((r) => post(r, { [author]: p }, files, '/u/' + author)).join('')
: (h.held ? '<p>Nothing from them yet.</p>' : '')
// ⛔ RENDERED here, unlike the one-line form in a listing. It is a markdown record body like any
// other, and the profile page is the one place with room for what the author actually wrote.
const bio = p.bio ? '<div class="bio">' + render(p.bio, blobResolver(files)) + '</div>' : ''
/**
* ⛔ The block button says what a block actually DOES, because the honest answer is weaker than
* the word suggests. A feed is public and replication is the reader's to perform, so this cannot
* stop them reading you, holding your feed, or writing about you. It stops YOUR client rendering
* them. A button labelled "block" with nothing said would promise enforcement we cannot deliver.
*/
/**
* ⛔ The way to @ somebody. A mention names an identity ROOT and a bare `@name` is never a guess
* (markdown-strip §3.4), so without this the feature was readable and unwritable: nothing in the
* interface showed anyone's root, and nobody is going to type one.
*
* Same shape as quote — it seeds the composer with ordinary text the author can edit.
*/
const mentionable = p.root
? '<a class="file" href="/?mention=' + encodeURIComponent(p.root) +
'&handle=' + encodeURIComponent(p.handle || p.name || 'them') + '">@ mention them</a> '
: ''
const blocked = h.blocked === true
const blockBtn = '<form method="post" action="' + (blocked ? '/unblock' : '/block') + '">' +
'<input type="hidden" name="feed" value="' + esc(author) + '">' +
'<input type="hidden" name="from" value="' + esc('/u/' + author) + '">' +
'<button>' + (blocked ? 'unblock' : 'block') + '</button></form>' +
'<p class="who">' + (blocked
? 'You are not rendering their posts. Their feed is still on disk and still replicating.'
: 'Hides their posts from your timeline, search and mentions. It does not stop them ' +
'reading your feed or writing about you — nothing here can.') + '</p>'
// ⚠ When the feed publishes NO name the heading already says so, and the key prefix becomes the
// only thing identifying whose page this is — so it is shown, and the full key still collapses.
const named = !!(p.handle || p.name)
const prefix = named ? '' : '<p class="who"><code>' + short(author) + '…</code></p>'
return layout(p.name || 'profile', pic + name + claim + bio + prefix +
keyline('feed key', author) +
'<div class="choices">' + mentionable + '</div>' + act + blockBtn + body)
}
/**
* The handle half of settings.
*
* ⛔ This page is WHY the whole naming path exists, and it had no form on it — the daemon could
* claim a name and nothing on the surface could ask it to. Since the CLI has no installed launcher
* either (`pgh.bin` is spent on the daemon), there was in practice NO way for a person to claim a
* name at all. A capability with no door is not shipped.
*
* ⚠ The namespace key is shown because a namespace is a SUBSCRIPTION, not an authority: this node
* generated its own on first boot, so two nodes that never exchange keys resolve handles in
* different worlds. Nobody can share what the page never displays.
*/
export function handleSettings (handle, namespace) {
const ns = namespace && namespace.key
? '<p class="who">Claimed in namespace <code>' + esc(namespace.key.slice(0, 16)) + '…</code> — ' +
esc(String(namespace.claims == null ? 0 : namespace.claims)) + ' claim(s). ' +
'Handles resolve only for people on this same namespace.</p>'
: ''
if (handle) {
return '<h2>Handle</h2><p><b>@' + esc(handle) + '</b></p>' +
// ⚠ Releasing is stated honestly: the number retires with it. NAMING.md forbids recycling a
// number, because a familiar handle pointing at someone new is the one failure this design
// can actually cause.
'<p class="who">Releasing it retires that number for good — reclaiming the same name gives ' +
'you the next one, never this one again.</p>' +
'<form method="post" action="/handle">' +
'<input type="hidden" name="release" value="' + esc(handle) + '">' +
'<button>release @' + esc(handle) + '</button></form>' + ns
}
return '<h2>Handle</h2>' +
'<p class="who">A name claimed in a namespace, unique within it, and proved to belong to this ' +
'feed. Different from the display name above, which anyone may also use.</p>' +
'<form method="post" action="/handle">' +
'<p><label>Claim a name<br><input type="text" name="name" placeholder="alice"></label></p>' +
// ⛔ The rules are stated BEFORE the form is submitted, not as an error afterwards. A number is
// always appended, so nothing can be squatted and no claim is ever refused for being late.
'<p class="who">You get a number on the end — the first <code>alice</code> is ' +
'<code>alice0</code>, the next is <code>alice1</code>. So a name is never taken. ' +
'Letters, digits, dot, dash and underscore; it may not END in a digit, since that is where ' +
'the number goes.</p>' +
'<button>claim</button></form>' + ns
}
/**
* The namespace half of settings — the JOIN FORM and the ceremony around it.
*
* ⛔ This exists because the CLI has no door on an installed node: `pgh.bin` is a single string and
* favela-peer spends it on the daemon, so `favela namespace --join` is unreachable on a machine
* that installed the app rather than checked it out. The browser is the only surface a person
* actually has, and a namespace nobody can join is the same as no namespace at all.
*
* ⚠ `token` is rendered ONCE, straight from the POST that minted it. It is deliberately not passed
* through a redirect: a secret in a query string lands in browser history and in any log that keeps
* request lines.
*/
export function namespaceSettings (namespace, token = null, known = []) {
const ns = namespace || {}
if (!ns.key) return ''
const minted = token
? '<p class="warn">Invite: <code>' + esc(token) + '</code><br>' +
'Shown once and stored nowhere — the log holds only its hash. Give it to the other machine, ' +
'which redeems it with “ask to carry”.</p>'
: ''
// ⛔ Carrying and joining are different facts. A node that only resolves handles here is not
// broken; it simply cannot admit anyone, and nothing about a key says which you are.
const status = ns.carrying
? '<p class="who">This node carries the log — it can invite and admit.</p>'
: '<p class="who">This node reads this namespace but does not carry it. ' +
'Ask an admin for an invite to help carry it.</p>'
const alone = ns.founded && ns.carrying && (ns.admins || []).length === 1 && !(ns.writers || []).length
? '<p class="who">Nobody else has joined yet. Share the key above.</p>'
: ''
// ⛔ THE LIST IS THE DOOR. The join form below promises the namespace you leave stays on disk and
// you can come back to it; without this, coming back meant having written down 64 hex characters.
// A promise nobody can act on is indistinguishable from losing the thing.
const others = (known || []).filter((n) => !n.current)
const held = (known || []).length > 1
? '<h3>Namespaces this node holds</h3><ul class="ns-list">' +
(known || []).map((n) => {
const label = '<code>' + esc(String(n.key).slice(0, 16)) + '…</code>'
if (n.current) return '<li>' + label + ' <span class="who">— you are here</span></li>'
return '<li>' + label +
' <form method="post" action="/namespace/join" class="inline">' +
'<input type="hidden" name="key" value="' + esc(n.key) + '"><button>switch</button></form>' +
// ⚠ Says what it does and what it does NOT do. "Forget" reads like deletion and is not:
// the log and every claim in it stay on disk.
' <form method="post" action="/namespace/forget" class="inline">' +
'<input type="hidden" name="key" value="' + esc(n.key) + '">' +
'<button title="removes it from this list; the log and its claims stay on disk">forget</button></form>' +
'</li>'
}).join('') + '</ul>' +
(others.length ? '<p class="who">Forgetting one removes it from this list only — its log and ' +
'every handle in it stay on disk, and joining by key brings it back.</p>' : '')
: ''
const join = held + '<form method="post" action="/namespace/join">' +
'<p><label>Join another namespace<br>' +
'<input type="text" name="key" placeholder="64 hex characters"></label></p>' +
// ⛔ Said plainly, because it is the one thing a person would fear: joining is not a migration
// and it destroys nothing.
'<p class="who">The one you are in now stays on disk — you can come back to it, handles and ' +
'all. Handles you claimed there will not resolve while you are away.</p>' +
'<button>join</button></form>'
let carrying = ''
if (ns.carrying) {
const reqs = (ns.requests || []).filter((r) => !(ns.writers || []).some((w) => w.writer === r.writer))
const asks = reqs.length
? '<h3>Nodes asking to carry this log</h3><ul>' + reqs.map((r) =>
'<li><code>' + esc(r.writer.slice(0, 16)) + '…</code> ' +
(r.invited ? '<b>invited</b> ' : '<span class="who">no invite — admit by hand</span> ') +
'<form method="post" action="/namespace/admit">' +
'<input type="hidden" name="writer" value="' + esc(r.writer) + '">' +
'<button>admit</button></form></li>').join('') + '</ul>'
: '<p class="who">Nobody is asking to carry it.</p>'
const unspent = (ns.invites || []).filter((i) => !i.used)
const open = unspent.length
? '<h3>Unspent invites</h3><ul>' + unspent.map((i) =>
'<li><code>' + esc(i.hash.slice(0, 16)) + '…</code> ' +
'<form method="post" action="/namespace/withdraw">' +
'<input type="hidden" name="hash" value="' + esc(i.hash) + '">' +
'<button>withdraw</button></form></li>').join('') + '</ul>'
: ''
carrying = '<form method="post" action="/namespace/invite"><button>mint an invite</button></form>' +
// ⚠ Stated, because "single use" and "expires" are the two things people assume and only one
// is true here. Expiry would need a clock inside apply, which is replayed on every peer.
'<p class="who">Single use. It never expires; withdraw it instead.</p>' +
open + asks
} else {
carrying = '<form method="post" action="/namespace/request">' +
'<p><label>Invite token (optional)<br>' +
'<input type="text" name="token" placeholder="from an admin of this namespace"></label></p>' +
'<p class="who">Without a token your request waits for an admin to answer it by hand. ' +
'With one, their node answers it on its own.</p>' +
'<button>ask to carry this log</button></form>'
}
return '<h2>Namespace</h2>' + minted +
keyline('namespace key', ns.key) + status + alone +
'<p class="who">' + esc(String((ns.admins || []).length)) + ' admin(s), ' +
esc(String((ns.writers || []).length)) + ' node(s) carrying, ' +
esc(String(ns.claims == null ? 0 : ns.claims)) + ' handle(s).</p>' +
carrying + join
}
/**
* ⚠ Three states, and SYSTEM is the default. Two states would force everyone who has already told
* their OS which they want to tell us again, and to keep telling us when they change their mind.
*/
export function themeSettings (current) {
const opt = (v, label) =>
'<form method="post" action="/theme">' +
'<input type="hidden" name="theme" value="' + esc(v) + '">' +
'<button' + ((current || 'system') === v ? ' class="liked"' : '') + '>' + esc(label) + '</button>' +
'</form>'
// One row: three mutually exclusive choices stacked read as three separate settings.
return '<h2>Appearance</h2><div class="choices">' +
opt('system', 'system', '') + opt('light', 'light', '') + opt('dark', 'dark', '') +
'</div><p class="who">System follows your device.</p>' +
'<p class="who">Stored in a cookie on this browser, so the page arrives in the right colours ' +
'rather than correcting itself after it loads.</p>'
}
/**
* Where this identity publishes.
*
* ⛔ OFF IS THE DEFAULT AND IT SAYS SO. A person who has never touched this must be able to see, in
* one glance, that nothing they write leaves favela. An unticked box that could mean "off" or
* "never configured" is the ambiguity this section exists to remove.
*
* ⛔ AND IT NAMES THE COST. Turning bsky on publishes under a public identity on a network that does
* not forget, and a post cannot be recalled from it. That belongs beside the switch, not in a
* document — the moment to say it is the moment somebody reaches for it.
*
* ⚠ A plain form. Every other control here works without JavaScript and so does this one: tick,
* submit, reload. A live toggle would be the first thing on this page that needed a script.
*/
// ── app updates ──────────────────────────────────────────────────────────────────────────────────
//
// ⛔ RENDERED FROM A FILE THE SHELL WROTE, not from anything this surface knows. favela-surface has no
// idea whether it is inside an Android worklet or on a desktop, and must not learn: the shell writes
// `app-update.json`, the page reads it, and neither knows the other's process model.
//
// ⛔ AND THE CONTROLS FOLLOW THE SHELL'S CAPABILITY, not our own optimism. `ota` and `pair` are
// separate flags for a reason recorded in shell-handoff.js: the page arrives by OTA and the shell by
// APK, so "can service update buttons" is answered by whatever shell is actually running. A page that
// renders a control the shell beneath it cannot honour is worse than one that renders nothing.
export function updateSettings (u) {
if (!u) return ''
// ⛔ THIS SECTION ANSWERS ONE QUESTION: why am I not on the latest? It used to print two truncated
// tree hashes and a note, which answers nothing — so every time an update did not apply, the only
// way to find out why was a cable, a laptop and somebody who knows the codebase. That is not a
// property a phone may have.
//
// ⛔ SO IT NAMES VERSIONS, AND IT NAMES THE OBSTACLE. A refusal is rendered in the words chooseBundle
// used; a staged bundle identical to the running one is said to be identical rather than offered as
// an update; and a bundle waiting for a restart says so. The hashes stay, underneath, because when
// the version numbers are equal they are the only thing that distinguishes two builds — which is a
// real state and one somebody created today by publishing one version twice.
const short = (h) => (h ? esc(String(h).slice(0, 12)) + '…' : 'none')
const ver = (v) => (v ? esc(v) : 'unknown')
// ⛔ AN UNSTAMPED BUILD SAYS SO. Bundles older than the version stamp carry no version at all, and
// printing "Running unknown" would read as a fault rather than as a fact about that build's age.
const running = u.version
? '<p><strong>Running ' + ver(u.version) + '</strong>' +
(u.builtAt ? ' <span class="who">built ' + esc(when(u.builtAt)) + '</span>' : '') + '</p>'
: '<p class="who">This build does not report its version — it predates the stamp.</p>'
// ⛔ REFUSED IS ITS OWN STATE, and the most important one: something IS waiting and it cannot be
// applied, so a restart button here would be a lie. chooseBundle's own sentence is used verbatim —
// it knows why, and rewording it would be a second place to keep in step.
if (u.refused && u.refused.reason) {
return '<h2>App updates</h2>' + running +
'<p class="warn">An update is downloaded but cannot be applied: ' + esc(u.refused.reason) + '</p>' +
'<p class="who">Nothing to do from here — this usually needs a newer app, not another restart.</p>' +
batteryLine(u) + facts(u, short) + checkedLine(u)
}
const waiting = updateWaiting(u)
if (waiting) {
return '<h2>App updates</h2>' + running +
'<p class="warn">' + ver(u.stagedVersion) + ' is downloaded and will apply when the node restarts.</p>' +
(u.ota
? '<p><button id="app-restart">restart now</button></p>'
: '<p class="who">This app cannot restart itself; close and reopen it.</p>') +
batteryLine(u) + facts(u, short) + checkedLine(u)
}
// ⛔ "Up to date" ONLY when nothing is staged that differs. A staged bundle equal to the running one
// is the ordinary case — the node re-verified what it is already running — and calling that an
// update is exactly what made a restart button that did nothing.
return '<h2>App updates</h2>' + running +
'<p>Up to date.</p>' +
batteryLine(u) + facts(u, short) + checkedLine(u)
}
// ⚠ ALWAYS SHOWN, IN EVERY STATE. A page that shows its inputs only when it agrees with you cannot be
// used to discover that it is wrong — which cost several hours when `running` was null on a fresh
// install and the banner silently required it.
function facts (u, short) {
return '<p class="who">running ' + short(u.running) + ' · staged ' + short(u.staged) +
(u.stagedVersion ? ' (' + esc(u.stagedVersion) + ')' : '') + '</p>'
}
/**
* ⛔ THE REASON UPDATES ARRIVE LATE OR NOT AT ALL, said out loud.
*
* A frozen app has no swarm, so the release watch is never told anything. Measured on an SM-S922U:
* Samsung's Freecess logs "freeze to.favela.surface", and an update published while the app was in
* the background did not arrive until it was foregrounded some minutes later. From the outside that
* is indistinguishable from a broken updater, and it was read as one — repeatedly, for a whole day.
*
* ⚠ Only shown when the shell actually ASKED and the answer was no. `null` means an older shell that
* cannot ask, and warning somebody about a state nothing has measured is how a page teaches people to
* ignore it.
*/
function batteryLine (u) {
if (u.batteryExempt !== false) return ''
return '<p class="warn">This phone is allowed to freeze the node in the background. While it is ' +
'frozen it has no network, so updates and posts only arrive while you have the app open.</p>' +
'<p><button id="app-battery">allow it to run in the background</button></p>'
}
function checkedLine (u) {
return (u.checkedAt ? '<p class="who">Last checked ' + esc(when(u.checkedAt)) + '</p>' : '') +
(u.note ? '<p class="who">' + esc(u.note) + '</p>' : '') +
(u.ota ? '<p><button id="app-check">check now</button> <span id="app-msg" class="who"></span></p>' : '') +
(u.ota ? '<script type="module">\n' + UPDATE_JS + '\n</script>' : '')
}
/**
* A time somebody can read.
*
* ⚠ Relative, because "2026-09-11T19:52:39.148Z" is not an answer to "is this recent?" — which is the
* only thing anyone asks of a timestamp on this page.
*/
function when (t) {
const ms = typeof t === 'number' ? t : Date.parse(t)
if (!ms) return String(t)
const secs = Math.round((Date.now() - ms) / 1000)
if (secs < 60) return 'just now'
if (secs < 3600) return Math.round(secs / 60) + ' min ago'
if (secs < 86400) return Math.round(secs / 3600) + ' hours ago'
return new Date(ms).toISOString().slice(0, 10)
}
// ⛔ THE ONLY THING THIS POSTS IS A VERB. `parseShellMessage` on the other side is an allow-list of
// three names and nothing else — no op passed through to a handler map, which is how a WebView bridge
// turns into a remote-call surface for anything that reaches the page.
const UPDATE_JS = `
const post = (op) => {
try { window.ReactNativeWebView.postMessage(JSON.stringify({ t: 'app-update', op })) }
catch { const m = document.getElementById('app-msg'); if (m) m.textContent = 'this app cannot do that' }
}
const check = document.getElementById('app-check')
if (check) check.addEventListener('click', (e) => {
e.preventDefault()
const m = document.getElementById('app-msg')
if (m) m.textContent = 'checking…'
post('check')
})
const restart = document.getElementById('app-restart')
if (restart) restart.addEventListener('click', (e) => { e.preventDefault(); post('restart') })
const battery = document.getElementById('app-battery')
if (battery) battery.addEventListener('click', (e) => { e.preventDefault(); post('battery') })
`
export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null, update = null, known = [], did = null) {
const p = profile || {}
const note = message ? '<p class="warn">' + esc(message) + '</p>' : ''
const shown = p.avatar
? '<p><img class="av-lg" src="/avatar/' + esc(p.avatar) + '" alt="your avatar"></p>'
: '<p class="who">No picture yet.</p>'
// The banner is shown at its own width rather than in the avatar's circle — they are different
// pictures with different shapes, and previewing one as the other teaches the wrong thing.
const shownBanner = p.banner
? '<p><img class="banner-lg" src="/avatar/' + esc(p.banner) + '" alt="your banner"></p>'
: ''
// ⛔ THE IDENTITY ROOT, which settings never showed. It named nothing but the feed key — a LOCATOR
// that changes with a feed migration — while the identity every post is signed under (the keet /
// hyperident public key, the authority of every `favela://` URI) was already in `profile.root` from
// the founder record and dropped here. Demoted like every key (keys.test.js): a short prefix in view,
// the whole thing one click away and selectable.
const identity = p.root
? '<p>Your identity <code>' + esc(String(p.root).slice(0, 12)) + '…</code></p>' +
keyline('full identity key', p.root) +
'<p class="who">The key your posts are signed under. It stays the same across devices and feed ' +
'moves, and it is what other people verify you by.</p>'
: '<p class="warn">This node has no identity yet, so it cannot post or claim a name.</p>'
return layout('settings', note + updateSettings(update) + blueskySection(did, reach) + fediverseSection(reach) +
'<h1>Settings</h1>' + identity + shown +
'<form method="post" action="/settings" enctype="multipart/form-data">' +
'<p><label>Display name<br><input type="text" name="name" value="' + esc(p.name || '') +
'" placeholder="what people see instead of 64 hex characters"></label></p>' +
'<p><label>Picture<br><input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp">' +
'</label></p>' +
'<p class="who">png, jpeg, gif or webp, up to 512 KiB. The type is read from the file itself, ' +
'not its name.</p>' +
shownBanner +
'<p><label>Banner<br><input type="file" name="banner" accept="image/png,image/jpeg,image/gif,image/webp">' +
'</label></p>' +
'<p class="who">The wide header image. Same formats, up to 1 MB — a banner is wide, so it gets a ' +
'bigger allowance than the avatar. Leaving either file empty leaves that picture as it was.</p>' +
// ⛔ The profile record has had a body slot from the start and nothing ever wrote it, so every
// screen that wanted to say who somebody IS had a name, a picture and 64 hex characters.
'<p><label>About you<br><textarea name="bio" rows="3" placeholder="a sentence or two — markdown">' +
esc(p.bio || '') + '</textarea></label></p>' +
'<p class="who">Markdown, up to 512 characters. It travels with your feed, so it is what other ' +
'people see next to your name.</p>' +
'<button>save</button></form>' +
handleSettings(handle, namespace) +
themeSettings(theme) +
namespaceSettings(namespace, token, known))
}
export function knocks (rows) {
const items = rows.length
// ⛔ A stranger who knocked has NO profile — we hold nothing of theirs. The key prefix is the
// only thing on the row that says who this is, so it stays on screen and only the rest folds.
? rows.map((k) => '<li><span class="av av-dot"></span><code>' + short(k.feed) + '…</code>' +
keyline('full key', k.feed) +
'<form method="post" action="/admit"><input type="hidden" name="feed" value="' + esc(k.feed) + '">' +
'<button>follow them</button></form></li>').join('')
: ''
// Accepting a knock FOLLOWS them. It does not give them access to your feed, and the button says
// so — a UI that said "accept" would imply the opposite of what happens.
const none = rows.length ? '' : empty('Nobody has knocked.',
'Ringing your doorbell is the one thing somebody can do without you holding their feed. When ' +
'they do, they appear here and you can choose to follow them.',
[{ href: '/search', label: 'search for people' }])
return layout('knocks', rows.length ? '<ul>' + items + '</ul>' : none)
}
export function error (message) {
return layout('error', '<p class="warn">' + esc(message) + '</p>')
}
// ── onboarding ───────────────────────────────────────────────────────────────────────────────────
//
// ⛔ THREE DOORS, AND THEY ARE NOT INTERCHANGEABLE. Create makes a new identity. Restore rebuilds one
// from its recovery phrase and needs nobody online. Pair copies one from a device that IS online.
// Only restore survives losing every device, and only pair avoids handling the phrase at all — so
// offering two of the three would strand somebody.
//
// ⚠ This is the screen a handset had no way to reach at all: creating an identity used to require
// `hyperident` on a machine with a shell.
export function welcome (message = null) {
const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
return layout('welcome', warn +
'<h1>Welcome to favela</h1>' +
'<p>This node has no identity yet. Everything you post is signed by one, so pick how to get it.</p>' +
'<h2>Create a new identity</h2>' +
'<p class="who">You will be shown a 24-word recovery phrase, once. It is the only way back if ' +
'you lose every device.</p>' +
'<form method="post" action="/welcome/create">' +
passphraseFields('passphrase', { hint: 'Encrypts the vault on this device. Optional, and there is no way to reset it.' }) +
'<button>create</button></form>' +
'<h2>I already have a recovery phrase</h2>' +
'<p class="who">Rebuilds an identity you already own. Works with nothing else switched on.</p>' +
'<form method="post" action="/welcome/restore">' +
'<textarea name="mnemonic" rows="3" placeholder="your 24 words" autocomplete="off"></textarea>' +
passphraseFields('passphrase', { hint: 'Encrypts the rebuilt vault on this device. Optional.' }) +
'<button>restore</button></form>' +
'<h2>Link this device to another</h2>' +
'<p class="who">Paste an invite from a device you are already signed in on — run ' +
'<code>favela pair</code> there. Never asks for your recovery phrase.</p>' +
'<form method="post" action="/welcome/pair">' +
'<textarea name="invite" rows="2" placeholder="invite" autocomplete="off"></textarea>' +
'<button>link</button></form>')
}
// ⛔ SHOWN ONCE, AND THAT IS THE POINT. The daemon returns the phrase from the mint and never stores
// it in the clear; there is no route that can show it again. If there were, "a stolen device yields a
// locked vault" would stop being true (hyper-social #48), so the absence is the feature.
export function minted (mnemonic, restored = false) {
const words = String(mnemonic).split(/\s+/)
const grid = words.map((w, i) => '<li><span class="who">' + (i + 1) + '</span> ' + esc(w) + '</li>').join('')
if (restored) {
return layout('restored', '<h1>Identity restored</h1>' +
'<p>This device is now signed by your identity. Your recovery phrase has not changed.</p>' +
'<p><a href="/">continue</a></p>')
}
return layout('write this down', '<h1>Write these words down</h1>' +
'<p class="warn">This is the only time they will be shown. There is no route that can show them ' +
'again, and nobody — including this node — can recover them for you.</p>' +
'<ol class="mnemonic">' + grid + '</ol>' +
'<p><button id="copy">copy</button> <span id="copied" class="who"></span></p>' +
'<p class="who">Store them somewhere a stolen phone cannot reach. If you set a passphrase, the ' +
'vault on this device is encrypted with it — but the phrase alone is enough to be you.</p>' +
'<form method="get" action="/"><button>I have written them down</button></form>' +
'<script type="module">\n' +
"const w = " + JSON.stringify(words.join(' ')) + ';\n' +
"document.getElementById('copy').addEventListener('click', async (e) => {\n" +
" e.preventDefault()\n" +
// ⚠ 127.0.0.1 is a secure context in Chromium, so clipboard works in the WebView. The fallback is
// not decoration: a viewer that refuses it must still let someone get the words out.
" try { await navigator.clipboard.writeText(w); document.getElementById('copied').textContent = 'copied' }\n" +
" catch { document.getElementById('copied').textContent = 'copy failed — select the words above' }\n" +
'})\n</script>')
}
// ── discovery ────────────────────────────────────────────────────────────────────────────────────
//
// ⛔ THE STEP NOBODY COULD TAKE. Joining a namespace resolves handles; it does not make you follow
// anyone, and a timeline is built ONLY from feeds you follow. So a person could join the fleet's
// namespace, search correctly, see a handle, and still have no route from "found them" to "seeing
// them" — there was no follow button anywhere near a search result. Two steps from a timeline with
// no visible path is indistinguishable from a broken network, and that is what it was reported as.
//
// ⛔ AND IT SHOWS EVERYONE BY DEFAULT. `/search` returns without asking the daemon when the box is
// empty — deliberate there, because an empty query is not a request for the whole world. A
// namespace is exactly the scope where "show me everyone" IS the question, so this asks with no
// query and gets `names.list()`.
export function discover (handles, following = [], profiles = {}, ns = null) {
const held = new Set((following || []).map((f) => (typeof f === 'string' ? f : f && f.feed)).filter(Boolean))
const rows = (handles || []).filter((h) => h.verdict !== 'MISMATCH')
const key = ns && ns.key
? '<p class="who">Namespace <code>' + esc(String(ns.key).slice(0, 16)) + '…</code></p>'
: ''
if (!rows.length) {
// ⛔ NAMES THE NAMESPACE IN THE EMPTY STATE. "Nobody is here" and "you are in the wrong
// namespace" look identical otherwise, and the second is the likely one — an invite token and a
// namespace key are both 64 hex characters, so joining the wrong thing succeeds silently.
return layout('discover', '<h1>Nobody here yet</h1>' + key +
'<p>No handles have been claimed in this namespace. If you expected people, check you are in ' +
'the right one — a namespace key and an invite token are both 64 characters, and joining the ' +
'wrong one looks like this.</p>' +
'<p><a href="/settings">namespace settings</a></p>')
}
const row = (h) => {
const p = (profiles || {})[h.feed] || {}
const name = p.name ? '<strong>' + esc(p.name) + '</strong> ' : ''
// ⚠ `unheld` is LABELLED, not hidden. It means "this claim exists and I do not hold the feed to
// check it against" — which is the normal state for everyone you have not followed yet, so
// hiding it would empty the very page that exists to find them.
const flag = h.verdict === 'confirmed' ? '' : ' <span class="who">[' + esc(h.verdict) + ']</span>'
const action = held.has(h.feed)
? '<span class="who">following</span>'
: '<form method="post" action="/follow" class="inline">' +
'<input type="hidden" name="feed" value="' + esc(h.feed) + '"><button>follow</button></form>'
// ⛔ LINKED, like search's rows already are (`nameRows` above). Without this the page could show
// you who exists and offer to follow them, but not let you LOOK at them first — which is the
// one thing anyone wants before following a stranger.
return '<li>' + name + '<a href="/u/' + esc(h.feed) + '">@' + esc(h.handle) + '</a>' + flag +
// ⛔ The key is always shown. A handle is a claim; the key is the unforgeable thing, and this
// page's whole job is handing someone a feed to follow.
' <code class="who">' + esc(String(h.feed).slice(0, 8)) + '</code> ' + action + '</li>'
}
return layout('discover', '<h1>People here</h1>' + key +
'<p class="who">Everyone who has claimed a handle in this namespace. Following one is what puts ' +
'their posts in your timeline — joining a namespace on its own shows you nothing.</p>' +
'<ul class="ns-list">' + rows.map(row).join('') + '</ul>')
}
// ── status ───────────────────────────────────────────────────────────────────────────────────────
//
// ⛔ THE PAGE EVERY DEAD-END WARNING SHOULD POINT AT. "1 record could not be read — the log arrived,
// the bytes did not" is accurate and useless on its own: it does not say WHOSE feed, or what would
// fix it, so the only way to find out was to ask someone with a shell. The same was true of every
// OTA failure until settings started printing the daemon's own last line — and that instrument
// found a real bug on its first run, after hours of guessing at it from the outside.
//
// ⚠ It reports STATE, never a verdict. A page that prints a conclusion cannot be used to discover
// the conclusion is wrong, which is exactly how a staged update sat invisible behind "Up to date".
export function status ({ me = null, ns = null, following = [], missing = [], profiles = {}, update = null } = {}) {
const short = (k) => esc(String(k || '').slice(0, 12))
const row = (k, v, why) => '<tr><td class="who">' + esc(k) + '</td><td>' + v +
(why ? '<br><span class="who">' + esc(why) + '</span>' : '') + '</td></tr>'
// The identity root of THIS node, from the same `profiles` map every byline uses.
const myRoot = me && me.feed && profiles && profiles[me.feed] ? profiles[me.feed].root || null : null
const held = (following || []).map((f) => (typeof f === 'string' ? f : f && f.feed)).filter(Boolean)
const gone = new Set((missing || []).map((f) => (typeof f === 'string' ? f : f && f.feed)).filter(Boolean))
const feedRows = held.length
? held.map((f) => {
const p = (profiles || {})[f] || {}
const name = p.name ? esc(p.name) : '<span class="who">no profile yet</span>'
// ⛔ THE TWO STATES A PERSON ACTUALLY HITS, named in the terms the warnings use.
const state = gone.has(f)
? '<span class="warn-inline">followed, not held</span>'
: (p.name ? 'held' : '<span class="warn-inline">held, profile not read</span>')
return '<tr><td><a href="/u/' + esc(f) + '"><code>' + short(f) + '</code></a></td>' +
'<td>' + name + '</td><td>' + state + '</td></tr>'
}).join('')
: '<tr><td colspan="3" class="who">You follow nobody yet — try <a href="/discover">discover</a>.</td></tr>'
return layout('status',
'<h1>Status</h1>' +
'<p class="who">What this node holds, and what it is still waiting for. Every warning elsewhere ' +
'in favela means one of the states below.</p>' +
'<h2>This node</h2><table>' +
row('feed', me && me.feed ? '<code>' + short(me.feed) + '</code>' : '<span class="who">unknown</span>') +
row('identity', me && me.linked
? 'linked' + (myRoot ? ' — <code>' + short(myRoot) + '</code>' + keyline('full identity key', myRoot) : '')
: '<span class="warn-inline">not linked</span>',
me && me.linked ? '' : 'This node cannot claim a name or sign until an identity is minted.') +
'</table>' +
'<h2>Namespace</h2><table>' +
row('key', ns && ns.key ? '<code>' + short(ns.key) + '</code>' : '<span class="who">none</span>',
'Handles resolve only within the namespace you are in.') +
row('claims', ns ? String(ns.claims == null ? '?' : ns.claims) : '?',
ns && ns.claims === 0 ? 'Nobody has claimed a handle here — you may be in the wrong namespace.' : '') +
row('carrying', ns && ns.carrying ? 'yes' : 'no',
ns && ns.carrying ? '' : 'This node reads the namespace but does not carry its log.') +
'</table>' +
'<h2>Feeds</h2>' +
// ⛔ THE ANSWER TO "the log arrived, the bytes did not". A feed can be followed without being
// held, and held without its records being readable — three states that look identical from a
// timeline, and are fixed by completely different things.
'<p class="who"><b>followed, not held</b> — the feed is in your follow list but has not ' +
'replicated. It arrives when a peer holding it is reachable.<br>' +
'<b>held, profile not read</b> — the log arrived and some bytes have not. That is what an ' +
'unreadable record means; it resolves as the content replicates.</p>' +
'<table><tr><th>feed</th><th>who</th><th>state</th></tr>' + feedRows + '</table>' +
(update
? '<h2>Updates</h2><table>' +
row('version', esc(update.version || 'unstamped')) +
row('running', short(update.running) || '<span class="who">the app\'s own bundle</span>') +
row('staged', short(update.staged) || '<span class="who">nothing staged</span>') +
row('last checked', esc(update.checkedAt || 'never')) +
// ⛔ The daemon's own words, verbatim. It is the only line that distinguishes "cannot reach
// the swarm" from "nothing published" from "downloading" — three different problems.
row('last message', update.note ? '<code>' + esc(update.note) + '</code>' : '<span class="who">none</span>') +
'</table>'
: ''))
}
// ── passphrase entry ─────────────────────────────────────────────────────────────────────────────
//
// ⛔ ALWAYS TWO BOXES. A passphrase is typed blind and, for a vault, there is nobody to reset it: get
// it wrong once here and the recovery secret you sealed is gone with it. The confirmation costs one
// field and removes the only unrecoverable typo in the product.
//
// ⚠ Compared on the SERVER as well as here. The inline check is a courtesy that fails closed if the
// script does not run; the route re-checks, because a page cannot be trusted to have.
export function passphraseFields (name = 'passphrase', { required = false, hint = null } = {}) {
const n = esc(name)
return '<p><label>' + (required ? 'Passphrase' : 'Passphrase (optional)') + '<br>' +
'<input type="password" name="' + n + '" autocomplete="new-password"' + (required ? ' required' : '') + '></label></p>' +
'<p><label>Confirm passphrase<br>' +
'<input type="password" name="' + n + '-confirm" autocomplete="new-password"' + (required ? ' required' : '') + '></label></p>' +
(hint ? '<p class="who">' + esc(hint) + '</p>' : '')
}
export function didCreate (message = null) {
const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
return layout('create an identity', warn +
'<h1>Create a Bluesky identity</h1>' +
'<p>This creates an identity that works between favela peers. It reaches no outside service, and ' +
'the Bluesky network will not see it until you publish it — a separate step.</p>' +
'<p class="warn">The next screen shows a recovery phrase. It is shown once, and nothing — ' +
'including this node — can show it to you again.</p>' +
'<form method="post" action="/did/create">' +
passphraseFields('passphrase', {
required: true,
hint: 'At least 8 characters. This encrypts the recovery vault on this device, and it is the ' +
'only thing protecting it. There is no way to reset it.'
}) +
'<button>create</button></form>' +
'<p><a href="/settings">cancel</a></p>')
}
/**
* The phrase, once.
*
* ⛔ RENDERED INTO THIS RESPONSE AND NOWHERE ELSE. No GET route can reproduce it, exactly as
* `minted()` does for the favela identity — if any could, "a stolen device yields a locked vault"
* would stop being true.
*
* ⚠ It reports whether the seal SUCCEEDED. The mint deliberately does not throw when sealing fails —
* the identity exists and the words are on screen, so a person who writes them down has lost nothing
* — which means this page is the only thing that can tell them the backup did not happen.
*/
export function didMinted (r) {
const words = String(r.mnemonic || '').split(/\s+/)
const grid = words.map((w, i) => '<li><span class="who">' + (i + 1) + '</span> ' + esc(w) + '</li>').join('')
const seal = r.sealed
? '<p class="who">A sealed copy is on this device, encrypted with your passphrase. The words above ' +
'are still the only thing that survives losing it.</p>'
: '<p class="warn">The recovery vault could NOT be written' +
(r.sealError ? ' (' + esc(r.sealError) + ')' : '') + '. These words are the only copy — write ' +
'them down before you leave this page.</p>'
return layout('write this down', '<h1>Write these words down</h1>' +
'<p class="warn">This is the only time they will be shown. There is no route that can show them ' +
'again, and nobody — including this node — can recover them for you.</p>' +
'<ol class="mnemonic">' + grid + '</ol>' +
'<p><button id="copy">copy</button> <span id="copied" class="who"></span></p>' +
seal +
'<p class="who">Identity <code>' + esc(r.did || '') + '</code></p>' +
'<form method="get" action="/settings"><button>I have written them down</button></form>' +
'<script type="module">\n' +
'const w = ' + JSON.stringify(words.join(' ')) + ';\n' +
"document.getElementById('copy').addEventListener('click', async (e) => {\n" +
' e.preventDefault()\n' +
// ⚠ 127.0.0.1 is a secure context in Chromium, so clipboard works in the WebView. The fallback is
// not decoration: a viewer that refuses it must still let someone get the words out.
" try { await navigator.clipboard.writeText(w); document.getElementById('copied').textContent = 'copied' }\n" +
" catch { document.getElementById('copied').textContent = 'copy failed — select the words above' }\n" +
'})\n</script>')
}
/**
* The confirmation in front of the one irreversible act in this flow.
*
* ⛔ ITS OWN SCREEN AND ITS OWN WORDS. `hyper-proto-peer`'s mint calls this "a PERMANENT, PUBLIC,
* IRREVERSIBLE append to a global log". A person should meet that sentence before the button, not
* after.
*/
export function didPublish (did, message = null) {
const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
return layout('publish identity', warn +
'<h1>Publish to the Bluesky network</h1>' +
'<p>Identity <code>' + esc(did) + '</code></p>' +
'<p class="warn">This appends your identity to a global public directory, permanently. It cannot ' +
'be undone, edited or deleted. Replacing it later leaves the old entry readable for ever.</p>' +
'<p class="who">Until you do this, your identity works between favela peers and the Bluesky ' +
'network cannot see it. Nothing else about favela depends on this step.</p>' +
'<form method="post" action="/did/publish"><button>publish</button></form>' +
'<p><a href="/settings">not now</a></p>')
}
export function bridgeRelease (name, message = null) {
const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
return layout('give up this name', warn +
'<h1>Give up ' + esc(name) + '?</h1>' +
'<p class="warn">This is permanent. The name returns to the pool, and if someone else takes it ' +
'they get a NEW identity — your followers do not transfer to them, and re-claiming it would not ' +
'give you yours back either.</p>' +
'<p class="who">Anyone already following you on that network stops receiving your posts.</p>' +
'<form method="post" action="/bridge/release"><button>give it up</button></form>' +
'<p><a href="/settings">keep it</a></p>')
}
/**
* Delete one of my posts or replies — the page that asks first.
*
* ⛔ IT SAYS WHERE ELSE IT GOES. The delete is a tombstone every peer honours, and the bridges retract
* the copies they published: bsky's record is deleted and the fediverse is sent a Delete. What it cannot
* do is recall copies somebody else already took — a screenshot, a server that ignores Delete — and a
* person should meet that before the button, as keet's own delete leaves the original in the log.
*/
export function deletePost (name, from = '/', message = null) {
const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
return layout('delete post', warn +
'<h1>Delete this post?</h1>' +
'<p>It disappears from favela for everyone who holds your feed, and it is deleted from bsky and ' +
'Mastodon (the fediverse) wherever it was published.</p>' +
'<p class="who">Copies other people or servers already took cannot be recalled.</p>' +
'<form method="post" action="/post/delete">' +
'<input type="hidden" name="name" value="' + esc(name) + '">' +
'<input type="hidden" name="from" value="' + esc(from) + '">' +
'<button>delete it</button></form>' +
'<p><a href="' + esc(from) + '">keep it</a></p>')
}
/**
* Ask a bridge to carry this identity — and put what it grants on-chain.
*
* ⛔ ONE SCREEN FOR ONE DECISION. The two halves are useless apart: a bridge carrying a DID that
* resolves nowhere, or a handle published permanently against a server that has never heard of you.
*
* ⛔ AND IT SAYS WHAT IS PERMANENT. The bridge can drop you; the PLC append cannot be undone. Those
* are different kinds of commitment and a person should meet the difference before the button.
*/
export function didCarry (did, message = null) {
const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
return layout('get carried', warn +
'<h1>Ask a bridge to carry this identity</h1>' +
'<p><code>' + esc(did) + '</code></p>' +
'<p>A bridge serves your signed records to Bluesky. It holds no key for you — it fetches what ' +
'this device signed and re-serves it — so this asks for reach, not for authority.</p>' +
'<form method="post" action="/did/carry">' +
'<p><label>Bridge key<br><input type="text" name="bridge" placeholder="64 hex characters" ' +
'autocomplete="off"></label></p>' +
'<p class="who">The bridge prints this when it starts.</p>' +
'<p><label>Name you want<br><input type="text" name="name" placeholder="alice" ' +
'autocomplete="off"></label></p>' +
'<p class="who">The bridge grants the full handle under its own domain — it owns that namespace, ' +
'so it is the only thing that can answer for the name.</p>' +
'<p><label>Recovery passphrase<br><input type="password" name="passphrase" ' +
'autocomplete="current-password"></label></p>' +
'<p class="who">Needed once. Changing where an identity lives is signed by its recovery key, not ' +
'by the key this device posts with — which is exactly why losing a device does not lose the ' +
'identity.</p>' +
'<p class="warn">⛔ The second half of this is permanent. Recording the handle and the server on ' +
'your identity is an append to a global public log: it cannot be undone or edited, and the old ' +
'state stays readable for ever. The bridge itself can let you go; this cannot.</p>' +
'<button>ask, and publish</button></form>' +
'<p><a href="/settings">cancel</a></p>')
}
// ── one section per network ──────────────────────────────────────────────────────────────────────
//
// ⛔ SPLIT BY NETWORK, NOT BY MECHANISM. This page used to have "Publishing" (two checkboxes and a
// DID box), "Bluesky identity", and "Bridge" — where "Bridge" was the FEDIVERSE one and Bluesky's
// bridge was a link inside a different card. Two sections offered "a bridge key" and only one named
// its network, so the obvious thing to do was paste the Bluesky key into the fediverse form. Somebody
// did exactly that, got an error, retried, and got a second error about channels.
//
// ⛔ AND IN DEPENDENCY ORDER, because the order is the thing that was invisible. A switch does
// nothing without an identity; an identity reaches nobody without a bridge. Each section now reads
// top to bottom as the sequence it actually is, and each step says what the one below it still needs.
//
// ⚠ ONE FORM PER NETWORK, which forces the toggle to be per-network too. The old single form read
// both checkboxes at once, so two forms would have meant saving one network silently switching the
// other off — `POST /reach` now takes one network and merges.
const switchFor = (network, label, on, blocked) =>
'<form method="post" action="/reach">' +
'<input type="hidden" name="network" value="' + esc(network) + '">' +
'<p><label><input type="checkbox" name="on" value="on"' + (on ? ' checked' : '') + '> ' + esc(label) +
'</label></p>' +
(blocked
? '<p class="warn">' + esc(blocked) + '</p>'
: '') +
'<button>save</button></form>'
/**
* Bluesky, end to end: who you are, who carries you, and whether anything is sent.
*
* ⚠ Absent when the daemon cannot answer — a surface talking to a plain favela-peer has no atproto
* verbs, and offering what cannot be done is worse than offering nothing.
*/
export function blueskySection (did, reach) {
if (!did) return ''
const on = reach && Array.isArray(reach.networks) && reach.networks.includes('bsky')
if (!did.did) {
return '<h2>Bluesky</h2>' +
'<p class="who">Nothing is sent to Bluesky yet. It needs an identity, and a bridge to carry it.</p>' +
'<p><a href="/did/create">Create a Bluesky identity</a></p>' +
'<p class="who">Free, and it reaches no outside service until you publish it.</p>'
}
// ⛔ THE THREE STEPS, always all three, always in this order. A page that showed only the step you
// were on could not tell you why the one above it had not taken effect.
const identity = '<p><strong>1. Identity</strong><br><code>' + esc(did.did) + '</code></p>' +
(did.sealed
? '<p class="who">Your recovery phrase is sealed in a vault on this device.</p>'
: '<p class="warn">No recovery vault on this device. If you did not write the phrase down, this ' +
'identity cannot be recovered.</p>')
const carried = did.handle
? '<p><strong>2. Carried by</strong><br><code>' + esc(did.handle) + '</code>' +
(did.pdsUrl ? ' <span class="who">via ' + esc(did.pdsUrl) + '</span>' : '') + '</p>'
: '<p><strong>2. Carried by</strong> — nobody yet</p>' +
'<p class="who">Until a bridge carries it, this identity names no handle and no server, so ' +
'Bluesky cannot resolve it however it was published.</p>' +
'<p><a href="/did/carry">Get carried by a bridge</a></p>'
const designated = reach && reach.credential === did.did
const designate = !reach
? ''
: designated
? '<p class="who">Posts go out under this identity.</p>'
: '<form method="post" action="/did/designate"><button>publish under this identity</button></form>'
// ⚠ KEPT, and secondary. Designating a DID held somewhere else is a real thing to want, and the
// button above covers the common case — but removing the field entirely would take away the only
// way to say it. It names its own network now, which the old shared box did not.
const other = '<details><summary class="who">Use a different Bluesky identity</summary>' +
'<form method="post" action="/reach">' +
'<p><label>DID to publish under<br><input type="text" name="credential" ' +
'value="' + esc((reach && reach.credential) || '') + '" placeholder="did:plc:…" autocomplete="off">' +
'</label></p>' +
'<p class="who">Leave empty to publish under the identity above. Nothing is sent to Bluesky until ' +
'one is chosen.</p><button>save</button></form></details>'
// ⛔ THE FOURTH STEP, AND IT IS NOT IMPLIED BY THE THIRD. The switch records CONSENT; it does not
// author anything. The fediverse bridge HOLDS your feed and projects from it server-side, so
// switching that on is genuinely all there is to do — but Bluesky's membrane is keyless and serves
// only what this device SIGNED, so until the device authors them the repo stays empty. Measured on a
// handset: identity correct, handle resolving, relay active, switch on, and an atproto repo holding
// one bootstrap record. Every step reported success and nothing had ever been published.
//
// ⚠ Only offered once there is somewhere for it to go. Publishing into an identity nobody carries
// writes records no one can resolve.
const publish = (on && did.handle)
? '<p><strong>4. Publish what you have written</strong></p>' +
'<p class="who">The switch above says posts MAY go to Bluesky. This is what sends them: your ' +
'profile, and every post whose room is public enough to leave. Posts already there are left ' +
'alone, so running it twice is safe.</p>' +
'<form method="post" action="/did/project">' +
'<button name="send" value="">see what would be published</button> ' +
'<button name="send" value="1">publish now</button></form>' +
'<p class="warn">Publishing sends your existing posts, not just new ones. A post on Bluesky ' +
'federates and cannot be recalled.</p>'
: ''
return '<h2>Bluesky</h2>' + identity + carried +
'<p><strong>3. Send posts there</strong></p>' + designate + other +
switchFor('bsky', 'Send my public posts to Bluesky', on,
!did.handle ? 'No bridge carries this identity yet, so nothing will be sent even with this on.' : null) +
'<p class="who">Plain text only, up to 300 characters — longer posts are withheld, not shortened. ' +
'A post sent there cannot be recalled.</p>' + publish
}
/**
* What a projection run did, in its own words.
*
* ⛔ THE LOG VERBATIM, not a summary. Every line is a decision about one record — sent, already there,
* withheld and why — and the withheld ones are the interesting half: "too long for bsky", "a members
* room reaches nothing". A count cannot express those, and the question a person actually has after
* pressing this is "what happened to the one I meant".
*/
export function projectResult (r, message = null) {
const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
const head = r.refused
? '<p class="warn">Nothing was published: ' + esc(r.refused) + '</p>'
: '<p><strong>' + (r.sent || 0) + '</strong> published · ' + (r.skipped || 0) + ' already there · ' +
(r.withheld || 0) + ' withheld' + (r.held ? ' · ' + r.held + ' held back' : '') + '</p>'
const log = Array.isArray(r.log) && r.log.length
? '<pre>' + esc(r.log.join('\n')) + '</pre>'
: '<p class="who">The run produced no output.</p>'
return layout('publish to bluesky', warn + '<h1>Publishing to Bluesky</h1>' + head + log +
'<p><a href="/settings">back to settings</a></p>')
}
/**
* Mastodon and the fediverse, end to end: the name a bridge granted, and whether anything is sent.
*/
export function fediverseSection (reach) {
if (!reach) return ''
const on = Array.isArray(reach.networks) && reach.networks.includes('fediverse')
const held = reach.fediverse
const name = held
? '<p><strong>1. Your name</strong><br><code>' + esc(held) + '</code></p>' +
'<p class="who">A bridge holds the signing key for that network and serves your feed under this ' +
'name. You hold the name; it holds the delivery.</p>' +
'<p><a href="/bridge/release">Give up this name</a></p>'
: '<p><strong>1. Your name</strong> — none yet</p>' +
'<form method="post" action="/bridge/claim">' +
'<p><label>Fediverse bridge key<br><input type="text" name="bridge" placeholder="64 hex characters" ' +
'autocomplete="off"></label></p>' +
'<p class="who">⚠ The key of a FEDIVERSE bridge. A Bluesky bridge has its own, and they are not ' +
'interchangeable — pasting one into the other fails in a way that does not say so.</p>' +
'<p><label>Name you want<br><input type="text" name="name" placeholder="alice" ' +
'autocomplete="off"></label></p>' +
'<p><label>Invite (only if that bridge is closed)<br><input type="text" name="invite" ' +
'placeholder="leave empty on an open bridge" autocomplete="off"></label></p>' +
'<button>ask for this name</button></form>'
return '<h2>Mastodon and the fediverse</h2>' + name +
'<p><strong>2. Send posts there</strong></p>' +
switchFor('fediverse', 'Send my public posts to the fediverse', on,
!held ? 'No bridge carries you yet, so nothing will be sent even with this on.' : null) +
'<p class="who">Keeps headings, lists and links. No length limit. A post sent there cannot be ' +
'recalled.</p>'
}