| … | … | @@ -101,6 +101,30 @@ export function keyline (label, key) { |
| 101 | 101 | '<code>' + esc(key) + '</code></details>' |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | /** |
| 105 | * What a page says when it has nothing to show. |
| 106 | * |
| 107 | * ⛔ "Nothing yet." is the one thing the reader already knows. An empty state has three jobs: name |
| 108 | * what is empty, say WHY it is empty, and offer the thing that fixes it. Bluesky's empty feed does |
| 109 | * all three and puts two buttons on the page; ours did none of them. |
| 110 | * |
| 111 | * ⚠ `why` is escaped, and actions are {href, label} rather than markup — an empty state is the |
| 112 | * place a caller is most tempted to pass a sentence with a link in it, and this file has already |
| 113 | * been bitten by markup arriving through a value. |
| 114 | */ |
| 115 | export function empty (title, why, actions = []) { |
| 116 | // ⚠ A next step is sometimes a POST — "make a room" is not a page you can navigate to. So an |
| 117 | // action is {href} OR {post}, and this renders the right element for each. Still no markup |
| 118 | // arriving through a value: the caller passes a URL and a label, never a fragment. |
| 119 | const links = actions.length |
| 120 | ? '<div class="choices">' + actions.map((a) => (a.post |
| 121 | ? '<form method="post" action="' + esc(a.post) + '"><button>' + esc(a.label) + '</button></form>' |
| 122 | : '<a class="file" href="' + esc(a.href) + '">' + esc(a.label) + '</a>')).join(' ') + '</div>' |
| 123 | : '' |
| 124 | return '<div class="empty"><p class="empty-title">' + esc(title) + '</p>' + |
| 125 | (why ? '<p class="who">' + esc(why) + '</p>' : '') + links + '</div>' |
| 126 | } |
| 127 | |
| 104 | 128 | const CSS = ` |
| 105 | 129 | /* ⛔ ONE colour and ONE spacing scale, as variables. Values scattered through rules is how a page |
| 106 | 130 | ends up with four greys that were each "close enough" at the time. */ |
| … | … | @@ -323,6 +347,12 @@ ul.people li{border-top:1px solid var(--line);padding:var(--s2) 0} |
| 323 | 347 | padding:.05rem .5rem;white-space:nowrap} |
| 324 | 348 | /* ⚠ The full key stays — it is the only unforgeable name on the page. It does not need to be the |
| 325 | 349 | loudest thing on it: dim and small, so it is available rather than shouting. */ |
| 350 | /* An empty state is a block with room around it, not a stray sentence where content would be. */ |
| 351 | .empty{border:1px solid var(--line);border-radius:12px;padding:var(--s4) var(--s3); |
| 352 | margin:var(--s3) 0;text-align:center;background:var(--bg-soft)} |
| 353 | .empty-title{font-weight:600;margin:0 0 var(--s1)} |
| 354 | .empty .who{margin:0 auto;max-width:26rem} |
| 355 | .empty .choices{justify-content:center;margin-top:var(--s3)} |
| 326 | 356 | .keyline{margin:var(--s1) 0;font-size:12px} |
| 327 | 357 | .keyline summary{color:var(--dim);cursor:pointer;list-style:none;width:fit-content} |
| 328 | 358 | .keyline summary::-webkit-details-marker{display:none} |
| … | … | @@ -609,13 +639,36 @@ function avatarOf (author, profiles) { |
| 609 | 639 | // post, and the daemon bounds the value for the same reason. |
| 610 | 640 | const QUICK = ['🔥', '😂', '👀'] |
| 611 | 641 | |
| 612 | | export function timeline (rows, { unreadable = 0, profiles = null, files = null, message = null, quote = null, mention = null } = {}) { |
| 642 | export function timeline (rows, { unreadable = 0, profiles = null, files = null, message = null, quote = null, mention = null, empty: state = null } = {}) { |
| 613 | 643 | const warn = unreadable |
| 614 | 644 | // ⛔ Never silent. "nothing here" and "nothing here, and N records I hold pointers for but |
| 615 | 645 | // cannot read" are different answers, and the daemon distinguishes them — so the page must too. |
| 616 | 646 | ? '<p class="warn">' + unreadable + ' record(s) could not be read — the log arrived, the bytes did not.</p>' |
| 617 | 647 | : '' |
| 618 | | const body = rows.length ? rows.map((r) => post(r, profiles, files, '/')).join('') : '<p>Nothing yet.</p>' |
| 648 | /** |
| 649 | * ⛔ TWO KINDS OF EMPTY, and they are not the same problem. |
| 650 | * |
| 651 | * follows 0 there is nothing to read because nothing was asked for. The fix is to find |
| 652 | * somebody, and the page says where. |
| 653 | * follows N N feeds were asked for and none of them has arrived yet. The fix is time, or a |
| 654 | * look at which ones are missing — and telling this reader to "follow somebody" |
| 655 | * would be advice they have already taken. |
| 656 | * |
| 657 | * Conflating them is the UI version of drawing a converged edge and a resolved one the same way. |
| 658 | */ |
| 659 | const nothing = !state |
| 660 | ? empty('Nothing yet.', null, []) |
| 661 | : (state.follows === 0 |
| 662 | ? empty('Your timeline is empty.', |
| 663 | 'A timeline is made of the feeds you hold, and you hold none yet. Find somebody, or ' + |
| 664 | 'paste a feed key if you already have one.', |
| 665 | [{ href: '/search', label: 'search for people' }, { href: '/following', label: 'paste a key' }]) |
| 666 | : empty('Nothing has arrived yet.', |
| 667 | 'You follow ' + state.follows + ' feed(s). None of them has anything this node can read ' + |
| 668 | 'so far — replication takes as long as it takes, and a feed that never arrives shows as ' + |
| 669 | 'not replicated in your list.', |
| 670 | [{ href: '/following', label: 'see who you follow' }])) |
| 671 | const body = rows.length ? rows.map((r) => post(r, profiles, files, '/')).join('') : nothing |
| 619 | 672 | return layout('favela', composer(message, quote, mention) + warn + body) |
| 620 | 673 | } |
| 621 | 674 | |
| … | … | @@ -722,11 +775,16 @@ export function search (query, results, unreadable, people = [], profiles = null |
| 722 | 775 | export function mentions (rows, profiles = null, files = null) { |
| 723 | 776 | const body = rows.length |
| 724 | 777 | ? rows.map((r) => post(r, profiles, files, '/mentions')).join('') |
| 725 | | : '<p>Nobody you hold has named you.</p>' |
| 726 | | return layout('mentions', body + |
| 727 | | '<p class="who">Only posts from feeds you hold are searched, and only a ' + |
| 728 | | '<code>[@name](favela://…)</code> link counts — a bare <code>@name</code> is plain text, never ' + |
| 729 | | 'a guess about who was meant.</p>') |
| 778 | : empty('Nobody you hold has named you.', |
| 779 | 'And only a [@name](favela://…) link counts — a bare @name is plain text, never a guess ' + |
| 780 | 'about who was meant.', |
| 781 | [{ href: '/following', label: 'see who you follow' }]) |
| 782 | const note = rows.length |
| 783 | ? '<p class="who">Only posts from feeds you hold are searched, and only a ' + |
| 784 | '<code>[@name](favela://…)</code> link counts — a bare <code>@name</code> is plain text, ' + |
| 785 | 'never a guess about who was meant.</p>' |
| 786 | : '' |
| 787 | return layout('mentions', body + note) |
| 730 | 788 | } |
| 731 | 789 | |
| 732 | 790 | /** |
| … | … | @@ -779,11 +837,17 @@ export function notifications (rows, profiles = null) { |
| 779 | 837 | const snippet = n.body ? '<div class="who bio">' + esc(n.body.replace(/\s+/g, ' ').trim().slice(0, 120)) + '</div>' : '' |
| 780 | 838 | return '<li>' + who(n.author, profiles) + ' ' + verb + link + snippet + '</li>' |
| 781 | 839 | }).join('') + '</ul>' |
| 782 | | : '<p>Nothing yet.</p>' |
| 783 | | return layout('notifications', items + |
| 784 | | '<p class="who">Derived from feeds you already hold — nothing is sent to you. Someone whose ' + |
| 785 | | 'feed you do not hold can interact with you and this will not show it; ringing your doorbell ' + |
| 786 | | 'is the one thing they can always do.</p>') |
| 840 | : empty('No notifications.', |
| 841 | 'These are derived from feeds you already hold — nothing is sent to you. Somebody whose feed ' + |
| 842 | 'you do not hold can interact with you and this will not show it; ringing your doorbell is ' + |
| 843 | 'the one thing they can always do.', |
| 844 | [{ href: '/knocks', label: 'see who knocked' }, { href: '/following', label: 'see who you follow' }]) |
| 845 | const note = rows.length |
| 846 | ? '<p class="who">Derived from feeds you already hold — nothing is sent to you. Someone whose ' + |
| 847 | 'feed you do not hold can interact with you and this will not show it; ringing your doorbell ' + |
| 848 | 'is the one thing they can always do.</p>' |
| 849 | : '' |
| 850 | return layout('notifications', items + note) |
| 787 | 851 | } |
| 788 | 852 | |
| 789 | 853 | /** |
| … | … | @@ -927,7 +991,10 @@ export function rooms (list) { |
| 927 | 991 | '<li><a href="/r/' + esc(r.key) + '"><b>' + esc(r.config.title || 'untitled room') + '</b></a>' + |
| 928 | 992 | '<div class="who">' + esc(String(r.members)) + ' member(s) · <code>' + esc(r.key.slice(0, 16)) + '…</code></div>' + |
| 929 | 993 | '</li>').join('') + '</ul>' |
| 930 | | : '<p>No rooms yet.</p>' |
| 994 | : empty('No rooms yet.', |
| 995 | 'A room is a place with its own members, and what you write in one is read by them. Make one, ' + |
| 996 | 'or paste a key somebody handed you into the box above.', |
| 997 | [{ post: '/rooms/create', label: 'make a room' }]) |
| 931 | 998 | return layout('rooms', |
| 932 | 999 | '<form class="inline-form" method="post" action="/rooms/join">' + |
| 933 | 1000 | '<input type="text" name="key" placeholder="room key (64 hex)"><button>join</button></form>' + |
| … | … | @@ -1010,7 +1077,11 @@ export function following (keys, missing, profiles = null) { |
| 1010 | 1077 | : '' |
| 1011 | 1078 | return '<li>' + who(k, profiles) + warn + bio + '</li>' |
| 1012 | 1079 | }).join('') + '</ul>' |
| 1013 | | : '<p>Following nobody.</p>' |
| 1080 | // ⚠ The follow form is already below; the empty state points AT it rather than repeating it. |
| 1081 | : empty('Following nobody.', |
| 1082 | 'Everything you read comes from a feed you hold. Search for a person, or paste a feed key ' + |
| 1083 | 'into the box below.', |
| 1084 | [{ href: '/search', label: 'search for people' }, { href: '/knocks', label: 'see who knocked' }]) |
| 1014 | 1085 | const form = '<form class="inline-form" method="post" action="/follow">' + |
| 1015 | 1086 | '<input type="text" name="feed" placeholder="feed key (64 hex)"><button>follow</button></form>' |
| 1016 | 1087 | return layout('following', form + items) |
| … | … | @@ -1308,10 +1379,14 @@ export function knocks (rows) { |
| 1308 | 1379 | keyline('full key', k.feed) + |
| 1309 | 1380 | '<form method="post" action="/admit"><input type="hidden" name="feed" value="' + esc(k.feed) + '">' + |
| 1310 | 1381 | '<button>follow them</button></form></li>').join('') |
| 1311 | | : '<li>Nobody has knocked.</li>' |
| 1382 | : '' |
| 1312 | 1383 | // Accepting a knock FOLLOWS them. It does not give them access to your feed, and the button says |
| 1313 | 1384 | // so — a UI that said "accept" would imply the opposite of what happens. |
| 1314 | | return layout('knocks', '<ul>' + items + '</ul>') |
| 1385 | const none = rows.length ? '' : empty('Nobody has knocked.', |
| 1386 | 'Ringing your doorbell is the one thing somebody can do without you holding their feed. When ' + |
| 1387 | 'they do, they appear here and you can choose to follow them.', |
| 1388 | [{ href: '/search', label: 'search for people' }]) |
| 1389 | return layout('knocks', rows.length ? '<ul>' + items + '</ul>' : none) |
| 1315 | 1390 | } |
| 1316 | 1391 | |
| 1317 | 1392 | export function error (message) { |