| … | … | @@ -600,6 +600,9 @@ details.post>summary{cursor:pointer;color:#888;font-style:italic;list-style:reve |
| 600 | 600 | .composerhint{font-size:.78rem;color:var(--fg-faint,#6b7480);margin:.4rem 0 0} |
| 601 | 601 | /* Which identity this form writes as. Above the textarea because it has to be read BEFORE typing, not |
| 602 | 602 | discovered after posting; strong enough to be seen, quiet enough not to shout on every page. */ |
| 603 | /* Staleness. Above everything, because it qualifies everything below it — but a notice, not an alarm: |
| 604 | it is frequently true and correctly ignorable on a P2P node. */ |
| 605 | .stale{font-size:.82rem;background:var(--bg-soft);border:1px solid var(--line);border-left:3px solid #b8860b;border-radius:var(--r1);padding:.45rem .6rem;margin:0 0 var(--s3)} |
| 603 | 606 | .composerwho{font-size:.8rem;color:var(--fg-faint,#6b7480);margin:0 0 .4rem} |
| 604 | 607 | .composerwho strong{color:var(--fg);font-weight:600} |
| 605 | 608 | @media(max-width:640px){ |
| … | … | @@ -1041,6 +1044,44 @@ export function settingsPage ({ viewer = null, profile = null, did = null, devic |
| 1041 | 1044 | } |
| 1042 | 1045 | |
| 1043 | 1046 | // The signed-in/anonymous indicator + sign-in/out link (the DID session layer wires these routes). |
| 1047 | // Is what you are reading current? The one state this UI never had. |
| 1048 | // |
| 1049 | // ⛔ THIS NODE IS DESIGNED TO BE INTERMITTENTLY ONLINE (favela-web/spec/mobile-node.md §5) and every page |
| 1050 | // rendered identically whether the index advanced a second ago or froze two days ago. The freeze |
| 1051 | // signature is invisible by construction: peers connected, daemon up, backfill "successful", profile |
| 1052 | // renders — just missing everything appended since. On 2026-08-18 the handset's node was dead for 68 |
| 1053 | // minutes behind a timeline that looked merely quiet. |
| 1054 | // |
| 1055 | // frontsDiag has computed the answer since the ashatron freeze; it was only ever rendered on /status, |
| 1056 | // which a reader never opens. This is the one sentence a reader needs, on the page they are on. |
| 1057 | // |
| 1058 | // ⛔ NEVER CLAIMS CURRENCY. The freshest front we can SEE may not be the freshest that exists, so "up to |
| 1059 | // date" is not a statement this node is entitled to make. Two things are sayable — "no peer is serving |
| 1060 | // this right now" and "we are behind by N on the line we are on" — and everything else renders NOTHING. |
| 1061 | // Silence here means "no reason to doubt", not "verified fresh"; a green tick would be the lie. |
| 1062 | export function freshnessNote (diag) { |
| 1063 | if (!diag || !Array.isArray(diag.dids) || !diag.dids.length) return '' |
| 1064 | |
| 1065 | // No live front = nobody answered for this identity. This is the handset's case, and the reader's |
| 1066 | // question ("is this all there is?") has a real answer we were withholding. |
| 1067 | const dark = diag.dids.filter((d) => !((d && d.fronts) || []).some((f) => f && f.live)) |
| 1068 | if (dark.length) { |
| 1069 | const n = dark.length |
| 1070 | const total = diag.dids.length |
| 1071 | return `<p class="stale">⚠ this view may be <strong>out of date</strong> — ${n} of ${total} ${n === 1 ? 'identity has' : 'identities have'} no live peer right now. <a href="/status">why</a></p>` |
| 1072 | } |
| 1073 | |
| 1074 | // On a line, but a shorter one than we can see: the freeze signature, in a reader's words. Stated as a |
| 1075 | // COUNT, because "syncing…" is what the old empty state said while nothing was happening. |
| 1076 | const behind = diag.dids.filter((d) => d && Number.isFinite(d.behindBy) && d.behindBy > 0) |
| 1077 | if (behind.length) { |
| 1078 | const worst = behind.reduce((a, b) => (b.behindBy > a.behindBy ? b : a)) |
| 1079 | return `<p class="stale">⚠ <strong>behind</strong> by ${worst.behindBy} record${worst.behindBy === 1 ? '' : 's'} on ${esc(shortDid(worst.did))}. <a href="/status">why</a></p>` |
| 1080 | } |
| 1081 | |
| 1082 | return '' |
| 1083 | } |
| 1084 | |
| 1044 | 1085 | function whoami (viewer) { |
| 1045 | 1086 | if (viewer && viewer.did) return `<span class="who">${esc(viewer.handle || viewer.did)} · <a href="/logout">sign out</a></span>` |
| 1046 | 1087 | return '<span class="who"><a href="/login">sign in</a></span>' |
| … | … | @@ -1107,7 +1148,7 @@ const POSTCLICK = `<script> |
| 1107 | 1148 | |
| 1108 | 1149 | // Full HTML document. `client` (optional) is the progressive-enhancement <script> src/inline appended |
| 1109 | 1150 | // before </body>; with it absent the page is fully functional server-rendered. |
| 1110 | | export function page ({ title = 'favela', heading, viewer = null, active = 'timeline', canWrite = false, body = '', clientScript = '', quoting = null, pds = 'https://pds.7rnx.net', composerLists = [] } = {}) { |
| 1151 | export function page ({ title = 'favela', heading, viewer = null, active = 'timeline', canWrite = false, body = '', clientScript = '', quoting = null, pds = 'https://pds.7rnx.net', composerLists = [], freshness = null } = {}) { |
| 1111 | 1152 | return `<!doctype html><html lang="en"><head><meta charset="utf-8"> |
| 1112 | 1153 | <meta name="viewport" content="width=device-width,initial-scale=1"> |
| 1113 | 1154 | <title>${esc(title)}</title> |
| … | … | @@ -1120,6 +1161,7 @@ export function page ({ title = 'favela', heading, viewer = null, active = 'time |
| 1120 | 1161 | ${nav(active, viewer)} |
| 1121 | 1162 | </header> |
| 1122 | 1163 | <main id="main" tabindex="-1"> |
| 1164 | ${freshnessNote(freshness)} |
| 1123 | 1165 | ${composer(canWrite, quoting, composerLists, viewer)} |
| 1124 | 1166 | ${body} |
| 1125 | 1167 | </main> |
| … | … | @@ -1132,7 +1174,7 @@ ${POSTCLICK} |
| 1132 | 1174 | } |
| 1133 | 1175 | |
| 1134 | 1176 | // ── whole-page views ────────────────────────────────────────── |
| 1135 | | export function timelinePage ({ result, viewer = null, canWrite = false, clientScript = '', active = 'timeline', emptyMsg = 'no posts yet (indexing…)', quoting = null, composerLists = [], apRkeys = null } = {}) { |
| 1177 | export function timelinePage ({ result, viewer = null, canWrite = false, clientScript = '', active = 'timeline', emptyMsg = 'no posts yet (indexing…)', quoting = null, composerLists = [], apRkeys = null, freshness = null } = {}) { |
| 1136 | 1178 | const base = active === 'following' ? '/following' : '/' |
| 1137 | 1179 | const posts = withReach(withViewer(result && result.posts, viewer, base), apRkeys) |
| 1138 | 1180 | return page({ |
| … | … | @@ -1142,6 +1184,7 @@ export function timelinePage ({ result, viewer = null, canWrite = false, clientS |
| 1142 | 1184 | canWrite, |
| 1143 | 1185 | quoting, |
| 1144 | 1186 | composerLists, |
| 1187 | freshness, |
| 1145 | 1188 | body: renderPosts(posts, emptyMsg) + moreLink(result && result.cursor, base), |
| 1146 | 1189 | // Composer image picker is progressive enhancement — only loaded when there is a composer to enhance. |
| 1147 | 1190 | clientScript: clientScript || (canWrite ? '<script type="module" src="/static/compose.js"></script>' : '') |