| … | … | @@ -565,7 +565,11 @@ function oneLine (text, max) { |
| 565 | 565 | * ⛔ The nav marks where you ARE. Eight identical underlined links told a reader nothing about which |
| 566 | 566 | * page they were on, which is most of what a navigation bar is for. |
| 567 | 567 | */ |
| 568 | const NAV_ITEMS = [['/', 'timeline'], ['/rooms', 'rooms'], ['/search', 'search'], ['/following', 'following'], | |
| 568 | // ⛔ `discover` SITS BESIDE `search`, and the pair is deliberate. Search answers "where is this | |
| 569 | // person I can name"; discover answers "who is here at all" — and until it existed only the first | |
| 570 | // question could be asked, so joining a namespace and then finding nobody was the whole experience. | |
| 571 | const NAV_ITEMS = [['/', 'timeline'], ['/rooms', 'rooms'], ['/search', 'search'], ['/discover', 'discover'], | |
| 572 | ['/following', 'following'], | |
| 569 | 573 | ['/notifications', 'notifications'], ['/mentions', 'mentions'], ['/knocks', 'knocks'], |
| 570 | 574 | ['/graph', 'graph'], ['/explore', 'explore'], |
| 571 | 575 | ['/settings', 'settings'], ['/logout', 'sign out']] |
| … | … | @@ -1663,3 +1667,57 @@ export function minted (mnemonic, restored = false) { |
| 1663 | 1667 | " catch { document.getElementById('copied').textContent = 'copy failed — select the words above' }\n" + |
| 1664 | 1668 | '})\n</script>') |
| 1665 | 1669 | } |
| 1670 | ||
| 1671 | // ── discovery ──────────────────────────────────────────────────────────────────────────────────── | |
| 1672 | // | |
| 1673 | // ⛔ THE STEP NOBODY COULD TAKE. Joining a namespace resolves handles; it does not make you follow | |
| 1674 | // anyone, and a timeline is built ONLY from feeds you follow. So a person could join the fleet's | |
| 1675 | // namespace, search correctly, see a handle, and still have no route from "found them" to "seeing | |
| 1676 | // them" — there was no follow button anywhere near a search result. Two steps from a timeline with | |
| 1677 | // no visible path is indistinguishable from a broken network, and that is what it was reported as. | |
| 1678 | // | |
| 1679 | // ⛔ AND IT SHOWS EVERYONE BY DEFAULT. `/search` returns without asking the daemon when the box is | |
| 1680 | // empty — deliberate there, because an empty query is not a request for the whole world. A | |
| 1681 | // namespace is exactly the scope where "show me everyone" IS the question, so this asks with no | |
| 1682 | // query and gets `names.list()`. | |
| 1683 | export function discover (handles, following = [], profiles = {}, ns = null) { | |
| 1684 | const held = new Set((following || []).map((f) => (typeof f === 'string' ? f : f && f.feed)).filter(Boolean)) | |
| 1685 | const rows = (handles || []).filter((h) => h.verdict !== 'MISMATCH') | |
| 1686 | ||
| 1687 | const key = ns && ns.key | |
| 1688 | ? '<p class="who">Namespace <code>' + esc(String(ns.key).slice(0, 16)) + '…</code></p>' | |
| 1689 | : '' | |
| 1690 | ||
| 1691 | if (!rows.length) { | |
| 1692 | // ⛔ NAMES THE NAMESPACE IN THE EMPTY STATE. "Nobody is here" and "you are in the wrong | |
| 1693 | // namespace" look identical otherwise, and the second is the likely one — an invite token and a | |
| 1694 | // namespace key are both 64 hex characters, so joining the wrong thing succeeds silently. | |
| 1695 | return layout('discover', '<h1>Nobody here yet</h1>' + key + | |
| 1696 | '<p>No handles have been claimed in this namespace. If you expected people, check you are in ' + | |
| 1697 | 'the right one — a namespace key and an invite token are both 64 characters, and joining the ' + | |
| 1698 | 'wrong one looks like this.</p>' + | |
| 1699 | '<p><a href="/settings">namespace settings</a></p>') | |
| 1700 | } | |
| 1701 | ||
| 1702 | const row = (h) => { | |
| 1703 | const p = (profiles || {})[h.feed] || {} | |
| 1704 | const name = p.name ? '<strong>' + esc(p.name) + '</strong> ' : '' | |
| 1705 | // ⚠ `unheld` is LABELLED, not hidden. It means "this claim exists and I do not hold the feed to | |
| 1706 | // check it against" — which is the normal state for everyone you have not followed yet, so | |
| 1707 | // hiding it would empty the very page that exists to find them. | |
| 1708 | const flag = h.verdict === 'confirmed' ? '' : ' <span class="who">[' + esc(h.verdict) + ']</span>' | |
| 1709 | const action = held.has(h.feed) | |
| 1710 | ? '<span class="who">following</span>' | |
| 1711 | : '<form method="post" action="/follow" class="inline">' + | |
| 1712 | '<input type="hidden" name="feed" value="' + esc(h.feed) + '"><button>follow</button></form>' | |
| 1713 | return '<li>' + name + '@' + esc(h.handle) + flag + | |
| 1714 | // ⛔ The key is always shown. A handle is a claim; the key is the unforgeable thing, and this | |
| 1715 | // page's whole job is handing someone a feed to follow. | |
| 1716 | ' <code class="who">' + esc(String(h.feed).slice(0, 8)) + '</code> ' + action + '</li>' | |
| 1717 | } | |
| 1718 | ||
| 1719 | return layout('discover', '<h1>People here</h1>' + key + | |
| 1720 | '<p class="who">Everyone who has claimed a handle in this namespace. Following one is what puts ' + | |
| 1721 | 'their posts in your timeline — joining a namespace on its own shows you nothing.</p>' + | |
| 1722 | '<ul class="ns-list">' + rows.map(row).join('') + '</ul>') | |
| 1723 | } |
| … | … | @@ -619,6 +619,21 @@ export async function route (req, res, ctx) { |
| 619 | 619 | return send(res, pages.mentions(rows, profiles, await fileTypes(rows, client))) |
| 620 | 620 | } |
| 621 | 621 | |
| 622 | // ⛔ NO QUERY, DELIBERATELY. `/search` returns without asking the daemon when the box is empty, | |
| 623 | // which is right there — an empty query is not a request for the whole world. A namespace is the | |
| 624 | // one scope where "show me everyone" IS the question, so this asks with none and gets the full | |
| 625 | // claim list. Without it, joining a namespace and then finding nobody was the reported experience. | |
| 626 | if (req.method === 'GET' && path === '/discover') { | |
| 627 | const named = await client.call('search-names', {}).catch(() => ({ handles: [] })) | |
| 628 | const ns = await client.call('namespace').catch(() => null) | |
| 629 | // Profiles are only available for feeds this node HOLDS, so most rows here have none — a | |
| 630 | // stranger's display name is unreachable until you follow them. The handle is what identifies | |
| 631 | // them until then, which is why it leads each row. | |
| 632 | const profiles = await client.call('profiles').catch(() => ({})) | |
| 633 | const following = await client.call('following').catch(() => []) | |
| 634 | return send(res, pages.discover(named.handles || [], following, profiles, ns)) | |
| 635 | } | |
| 636 | ||
| 622 | 637 | if (req.method === 'GET' && path === '/following') { |
| 623 | 638 | // ⛔ Profiles too. A following list that showed keys could not say WHO you follow, which is the |
| 624 | 639 | // only question that page answers. |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface", |
| 3 | "version": "0.0.53", | |
| 3 | "version": "0.0.54", | |
| 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, withUpdateBanner, updateSettings } 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, updateSettings, discover } from '../lib/pages.js' | |
| 6 | 6 | |
| 7 | 7 | const row = (over = {}) => ({ |
| 8 | 8 | author: 'ab'.repeat(32), collection: 'post', name: 'p1', |
| … | … | @@ -615,3 +615,40 @@ test('★ the update section names the running VERSION, not just tree hashes', ( |
| 615 | 615 | t.ok(old.includes('predates the stamp'), 'and an unstamped build says why it cannot tell you') |
| 616 | 616 | t.absent(old.includes('Running undefined'), 'never a fabricated version') |
| 617 | 617 | }) |
| 618 | ||
| 619 | // ── discovery ──────────────────────────────────────────────────────────────────────────────────── | |
| 620 | // | |
| 621 | // ⛔ The page exists because joining a namespace and then finding nobody was the whole reported | |
| 622 | // experience: a timeline is built only from feeds you FOLLOW, and nothing offered a follow button | |
| 623 | // next to a name. These pin the parts that made it useless before. | |
| 624 | test('★ discover lists everyone and offers a FOLLOW for each — the step that was missing', (t) => { | |
| 625 | const html = discover( | |
| 626 | [{ handle: 'tabertron0', feed: 'a'.repeat(64), verdict: 'unheld' }, | |
| 627 | { handle: 'stortron0', feed: 'b'.repeat(64), verdict: 'confirmed' }], | |
| 628 | [], {}, { key: '2c'.repeat(32) }) | |
| 629 | t.ok(html.includes('@tabertron0') && html.includes('@stortron0'), 'both are listed') | |
| 630 | t.is((html.match(/action="\/follow"/g) || []).length, 2, 'a follow button on each') | |
| 631 | // ⛔ `unheld` is the NORMAL state for anyone you have not followed. Hiding it would empty the very | |
| 632 | // page that exists to find them. | |
| 633 | t.ok(html.includes('unheld'), 'unheld is labelled, not hidden') | |
| 634 | }) | |
| 635 | ||
| 636 | test('someone already followed gets no redundant button', (t) => { | |
| 637 | const feed = 'a'.repeat(64) | |
| 638 | const html = discover([{ handle: 'tabertron0', feed, verdict: 'confirmed' }], [{ feed }], {}, null) | |
| 639 | t.ok(html.includes('following'), 'it says so') | |
| 640 | t.absent(html.includes('action="/follow"'), 'and does not offer to follow again') | |
| 641 | }) | |
| 642 | ||
| 643 | test('⛔ a MISMATCH is never offered — that claim points at someone else\'s feed', (t) => { | |
| 644 | const html = discover([{ handle: 'imposter0', feed: 'c'.repeat(64), verdict: 'MISMATCH' }], [], {}, null) | |
| 645 | t.absent(html.includes('imposter0'), 'a claim whose feed disagrees is not a person to follow') | |
| 646 | }) | |
| 647 | ||
| 648 | // ⛔ "Nobody is here" and "you are in the WRONG namespace" look identical otherwise — and the second | |
| 649 | // is the likely one, because an invite token and a namespace key are both 64 hex characters. | |
| 650 | test('the empty state names the namespace, so a wrong one is diagnosable', (t) => { | |
| 651 | const html = discover([], [], {}, { key: 'ab'.repeat(32) }) | |
| 652 | t.ok(html.includes('Nobody here yet')) | |
| 653 | t.ok(html.includes('abababab'), 'the key is shown so you can tell which namespace you are in') | |
| 654 | }) |