| … | … | @@ -1381,13 +1381,59 @@ export function themeSettings (current) { |
| 1381 | 1381 | 'rather than correcting itself after it loads.</p>' |
| 1382 | 1382 | } |
| 1383 | 1383 | |
| 1384 | export 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 | */ | |
| 1399 | export 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 | ||
| 1430 | export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null) { | |
| 1385 | 1431 | const p = profile || {} |
| 1386 | 1432 | const note = message ? '<p class="warn">' + esc(message) + '</p>' : '' |
| 1387 | 1433 | const shown = p.avatar |
| 1388 | 1434 | ? '<p><img class="av-lg" src="/avatar/' + esc(p.avatar) + '" alt="your avatar"></p>' |
| 1389 | 1435 | : '<p class="who">No picture yet.</p>' |
| 1390 | return layout('settings', note + | |
| 1436 | return layout('settings', note + reachSettings(reach) + | |
| 1391 | 1437 | '<h1>Settings</h1>' + shown + |
| 1392 | 1438 | '<form method="post" action="/settings" enctype="multipart/form-data">' + |
| 1393 | 1439 | '<p><label>Display name<br><input type="text" name="name" value="' + esc(p.name || '') + |
| … | … | @@ -89,7 +89,11 @@ async function settingsPage (client, message, token = null, theme = null) { |
| 89 | 89 | if (ns && ns.carrying) { |
| 90 | 90 | ns.invites = (await client.call('namespace-invites').catch(() => ({ invites: [] }))).invites || [] |
| 91 | 91 | } |
| 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) | |
| 93 | 97 | } |
| 94 | 98 | |
| 95 | 99 | /** |
| … | … | @@ -713,6 +717,30 @@ export async function route (req, res, ctx) { |
| 713 | 717 | return seeOther(res, '/settings') |
| 714 | 718 | } |
| 715 | 719 | |
| 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 | ||
| 716 | 744 | if (req.method === 'POST' && path === '/settings') { |
| 717 | 745 | // 2 MiB: the avatar cap is 512 KiB, and the slack is so an oversized picture is refused by the |
| 718 | 746 | // daemon with a message about its size rather than by the transport with nothing useful. |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface", |
| 3 | "version": "0.0.45", | |
| 3 | "version": "0.0.46", | |
| 4 | 4 | "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -1146,6 +1146,55 @@ test('★★ an empty timeline asks WHO you follow, and a full one does not', as |
| 1146 | 1146 | t.is(called('following').length, 0, 'not asked when there are posts') |
| 1147 | 1147 | }) |
| 1148 | 1148 | |
| 1149 | test('★★ 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 | ||
| 1174 | test('⛔ 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 | ||
| 1186 | test('⚠ 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 | ||
| 1149 | 1198 | test('shut down', async (t) => { |
| 1150 | 1199 | await new Promise((r) => server.close(r)) |
| 1151 | 1200 | await new Promise((r) => daemon.close(r)) |