OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / a86c5df

0.0.46: publishing toggles in settings
A Publishing section: which networks this identity sends posts to, ticked in settings and stored by the daemon in the identity's own feed. ⛔ OFF IS STATED, NOT IMPLIED. 'Nothing you write leaves favela' — an unticked box that could mean 'off' or 'never configured' is exactly the ambiguity worth removing. The cost sits beside the switch: a post sent to either network cannot be recalled from it, and turning a network off stops future posts rather than retracting the ones already sent. ⛔ ONE FIELD PER NETWORK. body() keeps the LAST value for a repeated field name, so two checkboxes sharing one name would collapse into a single answer and silently drop whichever was ticked first. Asserted both ways. ⚠ The daemon decides, not this page. set-reach is gated on an attested device; a refusal arrives here as an error and is shown rather than routed around. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a86c5dfeb86ce9e654ebd27e6aaa35861a97dff4
by plankatron <fleeky@gmail.com> · 2026-09-08T01:03:36.000Z

Changed files (4) +127 -4

lib/pages.js +48 -2
……@@ -1381,13 +1381,59 @@ export function themeSettings (current) {
13811381 'rather than correcting itself after it loads.</p>'
13821382}
13831383
1384export function settings (profile, message, handle = null, namespace = null, token = null, theme = null) {
1384
1385/**
1386 * Where this identity publishes.
1387 *
1388 * ⛔ OFF IS THE DEFAULT AND IT SAYS SO. A person who has never touched this must be able to see, in
1389 * one glance, that nothing they write leaves favela. An unticked box that could mean "off" or
1390 * "never configured" is the ambiguity this section exists to remove.
1391 *
1392 * ⛔ AND IT NAMES THE COST. Turning bsky on publishes under a public identity on a network that does
1393 * not forget, and a post cannot be recalled from it. That belongs beside the switch, not in a
1394 * document — the moment to say it is the moment somebody reaches for it.
1395 *
1396 * ⚠ A plain form. Every other control here works without JavaScript and so does this one: tick,
1397 * submit, reload. A live toggle would be the first thing on this page that needed a script.
1398 */
1399export function reachSettings (reach) {
1400 if (!reach) return ''
1401 const on = new Set(Array.isArray(reach.networks) ? reach.networks : [])
1402 // ⛔ ONE FIELD PER NETWORK. `body()` in the router keeps the last value for a repeated name, so
1403 // two checkboxes sharing one name would collapse into a single answer and silently drop whichever
1404 // the person ticked first.
1405 const box = (name, label, why) =>
1406 '<p><label><input type="checkbox" name="reach-' + esc(name) + '" value="on"' +
1407 (on.has(name) ? ' checked' : '') + '> ' + esc(label) + '</label>' +
1408 '<br><span class="who">' + esc(why) + '</span></p>'
1409
1410 const cred = reach.credential
1411 ? '<p class="who">Publishing as <code>' + esc(reach.credential) + '</code></p>'
1412 // identity.md §5.1 — with no designated credential the projection is withheld rather than
1413 // published under a guess. The page has to say that, or an enabled network looks like it works.
1414 : '<p class="warn">No identifier is designated, so nothing will be published even where a ' +
1415 'network is ticked. Link one on the machine running the daemon.</p>'
1416
1417 return '<h2>Publishing</h2>' +
1418 (on.size === 0
1419 ? '<p class="who">Nothing you write leaves favela.</p>'
1420 : '<p class="who">Posts you write in a public room are also sent to the networks ticked below.</p>') +
1421 '<form method="post" action="/reach">' +
1422 box('bsky', 'Bluesky', 'Plain text only, up to 300 characters. Longer posts are withheld, not shortened.') +
1423 box('fediverse', 'Mastodon and the fediverse', 'Keeps headings, lists and links. No length limit.') +
1424 cred +
1425 '<p class="who">⛔ A post sent to either network cannot be recalled from it. Turning a network ' +
1426 'off stops future posts; it does not retract the ones already sent.</p>' +
1427 '<button>save</button></form>'
1428}
1429
1430export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null) {
13851431 const p = profile || {}
13861432 const note = message ? '<p class="warn">' + esc(message) + '</p>' : ''
13871433 const shown = p.avatar
13881434 ? '<p><img class="av-lg" src="/avatar/' + esc(p.avatar) + '" alt="your avatar"></p>'
13891435 : '<p class="who">No picture yet.</p>'
1390 return layout('settings', note +
1436 return layout('settings', note + reachSettings(reach) +
13911437 '<h1>Settings</h1>' + shown +
13921438 '<form method="post" action="/settings" enctype="multipart/form-data">' +
13931439 '<p><label>Display name<br><input type="text" name="name" value="' + esc(p.name || '') +
lib/router.js +29 -1
……@@ -89,7 +89,11 @@ async function settingsPage (client, message, token = null, theme = null) {
8989 if (ns && ns.carrying) {
9090 ns.invites = (await client.call('namespace-invites').catch(() => ({ invites: [] }))).invites || []
9191 }
92 return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme)
92 // ⚠ Tolerated missing: an older daemon has no `reach` command, and settings must still render.
93 // Passing null makes the section absent rather than showing every network as off, which would be
94 // a claim this surface cannot support.
95 const reach = await client.call('reach').catch(() => null)
96 return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach)
9397}
9498
9599/**
……@@ -713,6 +717,30 @@ export async function route (req, res, ctx) {
713717 return seeOther(res, '/settings')
714718 }
715719
720 /**
721 * Where this identity publishes.
722 *
723 * ⛔ The daemon decides, not this page. `set-reach` is gated on an ATTESTED device — the same gate
724 * as posting — so a browser that is merely signed in cannot switch a network on. The refusal
725 * arrives here as an error and is shown; it is not something to route around.
726 *
727 * ⚠ An unticked box sends NOTHING, so the absent-means-empty reading is the correct one for a
728 * checkbox form: no `reach` field at all means every network off.
729 */
730 if (req.method === 'POST' && path === '/reach') {
731 const form = await body(req)
732 // ⚠ One field per network — see reachSettings(). An unchecked box sends nothing at all, so
733 // absence is the correct reading of "off" here.
734 const networks = ['bsky', 'fediverse'].filter((n) => form['reach-' + n])
735 try {
736 await client.call('set-reach', { networks })
737 } catch (err) {
738 return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
739 }
740 return seeOther(res, '/settings?m=' + encodeURIComponent(
741 networks.length ? 'Publishing to ' + networks.join(' and ') + '.' : 'Publishing nowhere but favela.'))
742 }
743
716744 if (req.method === 'POST' && path === '/settings') {
717745 // 2 MiB: the avatar cap is 512 KiB, and the slack is so an oversized picture is refused by the
718746 // daemon with a message about its size rather than by the transport with nothing useful.
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.45",
3 "version": "0.0.46",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
test/routes.test.js +49 -0
……@@ -1146,6 +1146,55 @@ test('★★ an empty timeline asks WHO you follow, and a full one does not', as
11461146 t.is(called('following').length, 0, 'not asked when there are posts')
11471147})
11481148
1149test('★★ the publishing toggles reach the daemon, one field per network', async (t) => {
1150 reset()
1151 answers = { 'set-reach': { networks: ['bsky'], credential: null } }
1152 const r = await request('POST', '/reach', {
1153 body: 'reach-bsky=on',
1154 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
1155 })
1156 t.is(r.status, 303)
1157 t.alike(called('set-reach')[0].args.networks, ['bsky'])
1158
1159 // ⛔ BOTH, not the last one. The form parser keeps the last value for a repeated field name, so
1160 // sharing one name across two checkboxes would silently drop whichever was ticked first.
1161 reset()
1162 await request('POST', '/reach', {
1163 body: 'reach-bsky=on&reach-fediverse=on',
1164 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
1165 })
1166 t.alike(called('set-reach')[0].args.networks, ['bsky', 'fediverse'])
1167
1168 // ⛔ An unchecked box sends nothing, so nothing means OFF — not "unchanged".
1169 reset()
1170 await request('POST', '/reach', { body: 'submitted=1', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' } })
1171 t.alike(called('set-reach')[0].args.networks, [])
1172})
1173
1174test('⛔ a daemon that REFUSES the change is reported, not routed around', async (t) => {
1175 // set-reach is gated on an attested device. A browser that is merely signed in gets a refusal,
1176 // and the person has to be told why rather than shown a page that looks like it worked.
1177 reset()
1178 answers = { 'set-reach': new Error('this device is not admitted to its own feed') }
1179 const r = await request('POST', '/reach', {
1180 body: 'reach-bsky=on', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
1181 })
1182 t.is(r.status, 303)
1183 t.ok(/not%20admitted|not admitted/.test(String(r.headers.location)), 'the reason travels: ' + r.headers.location)
1184})
1185
1186test('⚠ settings renders publishing state, and says OFF plainly', async (t) => {
1187 reset()
1188 answers = {
1189 whoami: { feed: 'ab'.repeat(32) }, profiles: {}, profile: {}, namespace: null,
1190 reach: { networks: [], credential: null }
1191 }
1192 const r = await request('GET', '/settings', { headers: { cookie } })
1193 t.ok(r.body.includes('Publishing'), 'the section is there')
1194 t.ok(r.body.includes('Nothing you write leaves favela'), 'and off is stated, not implied by an empty box')
1195 t.ok(r.body.includes('cannot be recalled'), 'and the cost is named beside the switch')
1196})
1197
11491198test('shut down', async (t) => {
11501199 await new Promise((r) => server.close(r))
11511200 await new Promise((r) => daemon.close(r))