| … | … | @@ -23,6 +23,30 @@ export function withTheme (html, theme) { |
| 23 | 23 | return html.replace('<html lang="en">', '<html lang="en" data-theme="' + theme + '">') |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | /** | |
| 27 | * A banner on EVERY page when an update is downloaded and waiting. | |
| 28 | * | |
| 29 | * ⛔ APPLIED AT SEND TIME, exactly like `withTheme`, so no page function has to know this exists and | |
| 30 | * none can forget to render it. A notice that only appears where someone remembered to put it is a | |
| 31 | * notice nobody sees on the page they were actually on. | |
| 32 | * | |
| 33 | * ⛔ STAGED-AND-DIFFERENT ONLY. Staged and identical means the node re-verified the bundle it is | |
| 34 | * already running, which is the ordinary case — announcing that as an update would train people to | |
| 35 | * ignore the banner, and then the real one does nothing. | |
| 36 | * | |
| 37 | * ⚠ It links to settings rather than restarting from here. Restarting is a thing the SHELL does and | |
| 38 | * only the shell knows whether it can; a banner that offered a button the shell cannot honour would | |
| 39 | * be the same defect `parseShell` keeps separate flags to avoid. | |
| 40 | */ | |
| 41 | export function withUpdateBanner (html, update) { | |
| 42 | const u = update || null | |
| 43 | if (!u || !u.staged || !u.running || u.staged === u.running) return html | |
| 44 | const bar = '<div class="update-bar"><a href="/settings">An update is ready — restart to apply</a></div>' | |
| 45 | // After the opening <body>, so it is the first thing in the document and cannot be pushed below | |
| 46 | // the fold by whatever the page happens to render first. | |
| 47 | return html.includes('<body>') ? html.replace('<body>', '<body>' + bar) : bar + html | |
| 48 | } | |
| 49 | ||
| 26 | 50 | export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]) |
| 27 | 51 | |
| 28 | 52 | const short = (k) => esc(String(k || '').slice(0, 8)) |
| … | … | @@ -291,6 +315,12 @@ input[type=file]::file-selector-button:hover{border-color:var(--dim);background: |
| 291 | 315 | background:var(--bg-soft);touch-action:none;cursor:grab;overflow:hidden;position:relative} |
| 292 | 316 | /* Block-level, or the canvas sits on a text baseline and overflows the box it was sized to fit. */ |
| 293 | 317 | .scene canvas{display:block} |
| 318 | /* The update notice. Deliberately loud — it is the one thing on the page that expires. */ | |
| 319 | .update-bar{background:var(--warn-bg,#8a5a00);text-align:center;padding:.5rem} | |
| 320 | .update-bar a{color:#fff;text-decoration:none;font-weight:600} | |
| 321 | .ns-list{list-style:none;padding:0} | |
| 322 | .ns-list li{margin:.35rem 0} | |
| 323 | form.inline{display:inline} | |
| 294 | 324 | .explorer{display:flex;flex-direction:column;gap:8px} |
| 295 | 325 | /* ⚠ Fullscreen paints its own black behind the element, so the ground has to be declared again or |
| 296 | 326 | a light-theme page turns into a light panel floating on black. */ |
| … | … | @@ -1291,7 +1321,7 @@ export function handleSettings (handle, namespace) { |
| 1291 | 1321 | * through a redirect: a secret in a query string lands in browser history and in any log that keeps |
| 1292 | 1322 | * request lines. |
| 1293 | 1323 | */ |
| 1294 | export function namespaceSettings (namespace, token = null) { | |
| 1324 | export function namespaceSettings (namespace, token = null, known = []) { | |
| 1295 | 1325 | const ns = namespace || {} |
| 1296 | 1326 | if (!ns.key) return '' |
| 1297 | 1327 | |
| … | … | @@ -1312,7 +1342,30 @@ export function namespaceSettings (namespace, token = null) { |
| 1312 | 1342 | ? '<p class="who">Nobody else has joined yet. Share the key above.</p>' |
| 1313 | 1343 | : '' |
| 1314 | 1344 | |
| 1315 | const join = '<form method="post" action="/namespace/join">' + | |
| 1345 | // ⛔ THE LIST IS THE DOOR. The join form below promises the namespace you leave stays on disk and | |
| 1346 | // you can come back to it; without this, coming back meant having written down 64 hex characters. | |
| 1347 | // A promise nobody can act on is indistinguishable from losing the thing. | |
| 1348 | const others = (known || []).filter((n) => !n.current) | |
| 1349 | const held = (known || []).length > 1 | |
| 1350 | ? '<h3>Namespaces this node holds</h3><ul class="ns-list">' + | |
| 1351 | (known || []).map((n) => { | |
| 1352 | const label = '<code>' + esc(String(n.key).slice(0, 16)) + '…</code>' | |
| 1353 | if (n.current) return '<li>' + label + ' <span class="who">— you are here</span></li>' | |
| 1354 | return '<li>' + label + | |
| 1355 | ' <form method="post" action="/namespace/join" class="inline">' + | |
| 1356 | '<input type="hidden" name="key" value="' + esc(n.key) + '"><button>switch</button></form>' + | |
| 1357 | // ⚠ Says what it does and what it does NOT do. "Forget" reads like deletion and is not: | |
| 1358 | // the log and every claim in it stay on disk. | |
| 1359 | ' <form method="post" action="/namespace/forget" class="inline">' + | |
| 1360 | '<input type="hidden" name="key" value="' + esc(n.key) + '">' + | |
| 1361 | '<button title="removes it from this list; the log and its claims stay on disk">forget</button></form>' + | |
| 1362 | '</li>' | |
| 1363 | }).join('') + '</ul>' + | |
| 1364 | (others.length ? '<p class="who">Forgetting one removes it from this list only — its log and ' + | |
| 1365 | 'every handle in it stay on disk, and joining by key brings it back.</p>' : '') | |
| 1366 | : '' | |
| 1367 | ||
| 1368 | const join = held + '<form method="post" action="/namespace/join">' + | |
| 1316 | 1369 | '<p><label>Join another namespace<br>' + |
| 1317 | 1370 | '<input type="text" name="key" placeholder="64 hex characters"></label></p>' + |
| 1318 | 1371 | // ⛔ Said plainly, because it is the one thing a person would fear: joining is not a migration |
| … | … | @@ -1478,7 +1531,7 @@ const restart = document.getElementById('app-restart') |
| 1478 | 1531 | if (restart) restart.addEventListener('click', (e) => { e.preventDefault(); post('restart') }) |
| 1479 | 1532 | ` |
| 1480 | 1533 | |
| 1481 | export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null, update = null) { | |
| 1534 | export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null, update = null, known = []) { | |
| 1482 | 1535 | const p = profile || {} |
| 1483 | 1536 | const note = message ? '<p class="warn">' + esc(message) + '</p>' : '' |
| 1484 | 1537 | const shown = p.avatar |
| … | … | @@ -1512,7 +1565,7 @@ export function settings (profile, message, handle = null, namespace = null, tok |
| 1512 | 1565 | '<button>save</button></form>' + |
| 1513 | 1566 | handleSettings(handle, namespace) + |
| 1514 | 1567 | themeSettings(theme) + |
| 1515 | namespaceSettings(namespace, token)) | |
| 1568 | namespaceSettings(namespace, token, known)) | |
| 1516 | 1569 | } |
| 1517 | 1570 | |
| 1518 | 1571 | export function knocks (rows) { |
| … | … | @@ -42,10 +42,13 @@ for (const f of (() => { try { return fs.readdirSync(STATIC_DIR).filter((x) => x |
| 42 | 42 | * A WeakMap keyed by `res` is scoped to exactly one exchange and needs no cleanup. |
| 43 | 43 | */ |
| 44 | 44 | const THEME = new WeakMap() |
| 45 | // ⛔ The same per-response shape as THEME, for the same reason: applied once at send time so no page | |
| 46 | // has to know the banner exists and none can forget it. | |
| 47 | const UPDATE = new WeakMap() | |
| 45 | 48 | |
| 46 | 49 | const send = (res, html, status = 200) => { |
| 47 | 50 | res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' }) |
| 48 | res.end(pages.withTheme(html, THEME.get(res) || null)) | |
| 51 | res.end(pages.withUpdateBanner(pages.withTheme(html, THEME.get(res) || null), UPDATE.get(res) || null)) | |
| 49 | 52 | } |
| 50 | 53 | |
| 51 | 54 | // The reader's theme, or null for system. A plain cookie — no JS, and the server needs to know it |
| … | … | @@ -85,6 +88,9 @@ async function settingsPage (client, message, token = null, theme = null, appUpd |
| 85 | 88 | const me = await client.call('whoami') |
| 86 | 89 | const mine = (await client.call('profiles'))[me.feed] || {} |
| 87 | 90 | const ns = await client.call('namespace') |
| 91 | // ⚠ Tolerated missing, like `reach`: an older daemon has no `namespaces` command and settings must | |
| 92 | // still render. An empty list makes the section absent rather than claiming this node holds none. | |
| 93 | const known = (await client.call('namespaces').catch(() => ({ namespaces: [] }))).namespaces || [] | |
| 88 | 94 | // Invites are a separate call: only an admin has any, and the namespace summary is fetched on |
| 89 | 95 | // every settings load by anyone. |
| 90 | 96 | if (ns && ns.carrying) { |
| … | … | @@ -101,7 +107,7 @@ async function settingsPage (client, message, token = null, theme = null, appUpd |
| 101 | 107 | // render at all rather than being fabricated from what this surface can see. |
| 102 | 108 | let update = null |
| 103 | 109 | try { update = appUpdate ? await appUpdate() : null } catch { /* stays absent rather than guessing */ } |
| 104 | return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach, update) | |
| 110 | return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach, update, known) | |
| 105 | 111 | } |
| 106 | 112 | |
| 107 | 113 | /** |
| … | … | @@ -184,6 +190,10 @@ export async function route (req, res, ctx) { |
| 184 | 190 | // Read once, before anything renders — the server has to know the theme before it writes the |
| 185 | 191 | // page, or the first paint is the wrong colour and then corrects itself. |
| 186 | 192 | THEME.set(res, themeFrom(req)) |
| 193 | // ⚠ Per request, because the state changes underneath a long-lived page: a node that stages an | |
| 194 | // update while someone is reading should tell them on their next navigation, not on their next | |
| 195 | // restart. It is a small local file, and absent everywhere there is no shell. | |
| 196 | try { if (ctx.appUpdate) UPDATE.set(res, await ctx.appUpdate()) } catch { /* no shell */ } | |
| 187 | 197 | const { client, sessions } = ctx |
| 188 | 198 | const url = new URL(req.url, 'http://localhost') |
| 189 | 199 | const path = url.pathname |
| … | … | @@ -722,6 +732,18 @@ export async function route (req, res, ctx) { |
| 722 | 732 | return res.end() |
| 723 | 733 | } |
| 724 | 734 | |
| 735 | // ⛔ REMOVES A LIST ENTRY, NEVER A LOG. The daemon refuses the current namespace and refuses a | |
| 736 | // founded one, and says why in both cases — the message is passed through verbatim because it is | |
| 737 | // the half that knows. | |
| 738 | if (req.method === 'POST' && path === '/namespace/forget') { | |
| 739 | const form = await body(req) | |
| 740 | try { | |
| 741 | await client.call('namespace-forget', { key: (form.key || '').trim() }) | |
| 742 | } catch (err) { | |
| 743 | return seeOther(res, '/settings?m=' + encodeURIComponent(err.message)) | |
| 744 | } | |
| 745 | return seeOther(res, '/settings') | |
| 746 | } | |
| 725 | 747 | if (req.method === 'POST' && path === '/namespace/join') { |
| 726 | 748 | const form = await body(req) |
| 727 | 749 | try { |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface", |
| 3 | "version": "0.0.51", | |
| 3 | "version": "0.0.52", | |
| 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", |
| … | … | @@ -2,7 +2,7 @@ |
| 2 | 2 | |
| 3 | 3 | import test from 'brittle' |
| 4 | 4 | import fs from '#node:fs' |
| 5 | import { timeline, search, following, knocks, post, esc, layout, who, person, settings, handleSettings, namespaceSettings, composer, mentions, notifications, thread as pages_thread } from '../lib/pages.js' | |
| 5 | import { timeline, search, following, knocks, post, esc, layout, who, person, settings, handleSettings, namespaceSettings, composer, mentions, notifications, thread as pages_thread, withUpdateBanner } from '../lib/pages.js' | |
| 6 | 6 | |
| 7 | 7 | const row = (over = {}) => ({ |
| 8 | 8 | author: 'ab'.repeat(32), collection: 'post', name: 'p1', |
| … | … | @@ -577,3 +577,27 @@ test('⛔ the page declares its own background, in both directions', (t) => { |
| 577 | 577 | t.ok(css.includes('[data-theme=dark]'), 'an explicit dark choice applies') |
| 578 | 578 | t.ok(css.includes(':root:not([data-theme=light])'), 'and a light choice beats the device default') |
| 579 | 579 | }) |
| 580 | ||
| 581 | // ── the update banner ──────────────────────────────────────────────────────────────────────────── | |
| 582 | // | |
| 583 | // ⛔ Applied at SEND time like `withTheme`, so no page can forget it. These pin the one distinction | |
| 584 | // that decides whether the notice means anything. | |
| 585 | test('★ the banner appears only when a DIFFERENT bundle is staged', (t) => { | |
| 586 | const page = '<html lang="en"><body><h1>x</h1></body></html>' | |
| 587 | const on = withUpdateBanner(page, { running: 'aaaa', staged: 'bbbb' }) | |
| 588 | t.ok(on.includes('update-bar'), 'a staged bundle that differs is an update') | |
| 589 | t.ok(on.includes('href="/settings"'), 'and it points at the page that can restart') | |
| 590 | ||
| 591 | // ⛔ The ordinary case: the node re-verified what it is already running. Announcing that as an | |
| 592 | // update trains people to ignore the banner, and then the real one does nothing. | |
| 593 | t.absent(withUpdateBanner(page, { running: 'aaaa', staged: 'aaaa' }).includes('update-bar'), | |
| 594 | 'staged and identical is NOT an update') | |
| 595 | t.absent(withUpdateBanner(page, null).includes('update-bar'), 'no shell, no banner') | |
| 596 | t.absent(withUpdateBanner(page, { running: 'aaaa', staged: null }).includes('update-bar'), | |
| 597 | 'nothing staged is not an update') | |
| 598 | }) | |
| 599 | ||
| 600 | test('the banner sits at the top of the document, not wherever a page happens to put it', (t) => { | |
| 601 | const out = withUpdateBanner('<html lang="en"><body><h1>x</h1></body></html>', { running: 'a', staged: 'b' }) | |
| 602 | t.ok(out.indexOf('update-bar') < out.indexOf('<h1>'), 'it cannot be pushed below the fold') | |
| 603 | }) |