OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / public-timeline.js

243 lines · javascript ref:
// The public face of this bridge, rendered as a FEED.
//
// ⛔ PUBLIC ROWS ONLY, AND THE CALLER HAS ALREADY FILTERED. This page has no login in front of it, so
// the filter IS the safety property — Mastodon delivers DIRECT MESSAGES to an inbox exactly as it
// delivers replies, through the same handler and into the same store. `buildFeed` withholds anything
// that cannot positively show it was public, and anything addressed to another peer. This file
// renders; it does not decide.
//
// ⛔ AND IT IS STILL SOMEBODY ELSE'S TEXT. Verified at the inbox says WHO sent it, not that it is safe
// to render. `buildFeed` strips markup and this escapes what is left: stripping alone leaves
// `<script>` readable as text, escaping alone leaves a remote server choosing this page's layout.
//
// ⚠ IT LOOKS LIKE A FEED ON PURPOSE. Not decoration: a post we published and a like somebody sent us
// sit in one stream, so a missing interaction is VISIBLE. Every interaction defect in this project so
// far was found by reading a log, later than it should have been.

const esc = (s) => String(s == null ? '' : s)
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')

const STYLE = `*{box-sizing:border-box}
body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;margin:0 auto;
padding:0 0 48px;max-width:600px;color:#0f1419;background:#fff}
header{position:sticky;top:0;background:rgba(255,255,255,.85);backdrop-filter:blur(12px);
border-bottom:1px solid #eff3f4;padding:14px 16px;z-index:2}
h1{font-size:19px;margin:0;font-weight:700}
.sub{color:#536471;font-size:13px;margin:2px 0 0}
.row{display:flex;gap:12px;padding:12px 16px;border-bottom:1px solid #eff3f4}
.row:hover{background:#f7f9f9}
.av{flex:0 0 40px;width:40px;height:40px;border-radius:50%;background:#1d9bf0;color:#fff;
display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;overflow:hidden}
.av img{width:100%;height:100%;object-fit:cover}
.main{min-width:0;flex:1}
.head{display:flex;gap:6px;align-items:baseline;flex-wrap:wrap}
.name{font-weight:700}
.head a{color:inherit;text-decoration:none}.head a:hover .name{text-decoration:underline}
.handle,.time{color:#536471;font-size:14px}
.verb{color:#536471;font-size:14px;margin:1px 0 0}
.body{margin:3px 0 0;white-space:pre-wrap;word-wrap:break-word}
.sub-link{display:block;margin-top:6px;font-size:13px;color:#536471;text-decoration:none;
border-left:2px solid #eff3f4;padding-left:8px}
.sub-link:hover{color:#1d9bf0}
.none{color:#536471;font-style:italic;padding:32px 16px;text-align:center}
nav{display:flex;gap:16px;padding:16px}
a{color:#1d9bf0;text-decoration:none}a:hover{text-decoration:underline}
.pill{font-size:12px;color:#536471;border:1px solid #eff3f4;border-radius:999px;padding:1px 8px}
.media{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px}
.media img{max-width:100%;max-height:420px;border-radius:14px;border:1px solid #eff3f4;display:block}
.att{font-size:13px;color:#536471;border:1px solid #eff3f4;border-radius:999px;padding:3px 10px}
.actions{display:flex;gap:18px;margin-top:8px;font-size:13px;color:#536471;align-items:center}
.act{cursor:default}.act.dim{opacity:.45}
details.act{cursor:pointer}details.act summary{list-style:none;display:inline-block}
details.act summary::-webkit-details-marker{display:none}
details.act[open] summary{color:#1d9bf0}
.who-list{margin:6px 0 0;padding:0 0 0 2px;list-style:none;font-size:13px}
.who-list li{padding:1px 0}
.replies{margin:8px 0 0 -52px;border-top:1px solid #eff3f4}
.replies .row{padding-left:52px;border-bottom:none;padding-bottom:4px}
footer{color:#536471;font-size:13px;padding:16px;text-align:center}
@media(prefers-color-scheme:dark){body{background:#000;color:#e7e9ea}
header{background:rgba(0,0,0,.85);border-color:#2f3336}.row{border-color:#2f3336}
.row:hover{background:#080808}.handle,.time,.verb,.none,.pill,footer{color:#71767b}
.sub-link{border-color:#2f3336;color:#71767b}.pill,.media img,.att,.replies{border-color:#2f3336}
.actions,.who-list{color:#71767b}}`

const when = (at) => {
  const d = new Date(at)
  return Number.isNaN(d.getTime()) ? '' : d.toISOString().replace('T', ' ').slice(0, 16) + 'Z'
}

// The avatar, or the first letter of whoever this is. A feed with blank circles reads as broken; a
// letter reads as "no picture", which is the truth.
const avatar = (a) => {
  if (a && a.avatar) return '<span class="av"><img src="' + esc(a.avatar) + '" alt=""></span>'
  const src = (a && (a.name || a.handle)) || '?'
  return '<span class="av">' + esc(String(src).replace(/^@/, '').charAt(0).toUpperCase() || '?') + '</span>'
}

// ⛔ OUR OWN PICTURES ARE SHOWN; A REMOTE ONE IS LINKED, NOT HOTLINKED. This page has no login in
// front of it, so an <img> pointing at a stranger's server hands that server every viewer's IP and a
// view count for our page. Mastodon proxies remote media for exactly this reason; we do not have a
// proxy, so we link and say what is there. The reader loses one click and nobody else learns who read
// the page.
//
// ⚠ Same-origin is decided on the HOST, not a prefix match: "ap.7rnx.net.evil.com" starts with our
// host and is not us.
function sameHost (url, host) {
  if (!host) return false
  try { return new URL(String(url)).host === host } catch { return false }
}

const isImage = (a) => typeof a.mediaType === 'string' && a.mediaType.startsWith('image/')

function renderAttachments (list, host) {
  if (!list || !list.length) return ''
  return '<div class="media">' + list.map((a) => {
    const label = a.name || 'attachment'
    if (isImage(a) && sameHost(a.url, host)) {
      return '<a href="' + esc(a.url) + '"><img src="' + esc(a.url) + '" alt="' + esc(label) + '" loading="lazy"></a>'
    }
    return '<a class="att" href="' + esc(a.url) + '" rel="nofollow noopener ugc">' +
      (isImage(a) ? '🖼 ' : '📎 ') + esc(label) + ' ↗</a>'
  }).join('') + '</div>'
}

// Where a handle goes.
//
// ⛔ A COMBINED PAGE WITH NO WAY INTO A PEER'S OWN IS HALF A FEATURE — you had to already know the
// URL to read one account, which is the same as it not being there. A handle on THIS host goes to
// that peer's page; anyone else's goes to their own server.
//
// ⚠ THE HOST HALF IS COMPARED WHOLE. `@ap.7rnx.net@evil.example` and a name that merely contains our
// host are not us, and rewriting either into a local path would put a stranger behind one of our
// pages. Split on the LAST '@' — a handle is `@name@host` and the name cannot contain one.
function authorHref (a, host) {
  const h = a && a.handle
  if (typeof h === 'string' && host) {
    const at = h.lastIndexOf('@')
    if (at > 0) {
      const name = h.slice(1, at)
      const hostPart = h.slice(at + 1)
      if (hostPart === host && /^[A-Za-z0-9_.-]+$/.test(name)) {
        return { href: '/ap/users/' + encodeURIComponent(name) + '/timeline', local: true }
      }
    }
  }
  return a && a.url ? { href: a.url, local: false } : null
}

function renderRow (r, host) {
  const a = r.author || {}
  const name = a.name || String(a.handle || '').replace(/^@/, '').split('@')[0] || 'someone'
  const href = authorHref(a, host)
  const wrap = (inner) => href
    ? '<a href="' + esc(href.href) + '"' + (href.local ? '' : ' rel="nofollow noopener ugc"') + '>' + inner + '</a>'
    : inner
  const head = '<div class="head">' + wrap('<span class="name">' + esc(name) + '</span>' +
    (a.handle ? '<span class="handle">' + esc(a.handle) + '</span>' : '')) +
    (r.at ? '<span class="time">· ' + esc(when(r.at)) + '</span>' : '') +
    (r.source === 'fediverse' ? '<span class="pill">fediverse</span>' : '') + '</div>'

  // ⛔ THE VERB IS SHOWN IN WORDS. "Like" as a bare type is a protocol detail; "liked your post" is
  // what happened, and it is the line that makes a missing interaction obvious at a glance.
  const verb = r.verb ? '<p class="verb">' + esc(r.verb) + '</p>' : ''
  const body = r.text ? '<p class="body">' + esc(r.text) + '</p>' : ''
  // Whose post the subject is depends on which way the interaction went, and saying it the wrong way
  // round is exactly the confusion this page exists to remove.
  const label = r.source === 'own'
    ? 'the post you acted on ↗'
    : (r.kind === 'reply' ? 'in reply to your post ↗' : 'your post ↗')
  const back = r.subject
    ? '<a class="sub-link" href="' + esc(r.subject) + '" rel="nofollow noopener ugc">' + label + '</a>'
    : ''
  const link = r.url && !r.subject
    ? '<a class="sub-link" href="' + esc(r.url) + '" rel="nofollow noopener ugc">open ↗</a>'
    : ''
  const media = renderAttachments(r.attachments, host)
  return '<article class="row">' + avatar(a) + '<div class="main">' + head + verb + body + media + back + link +
    renderActions(r) + renderReplies(r, host) + '</div></article>'
}

// The interaction bar under a post.
//
// ⛔ WHO, NOT JUST HOW MANY. A count says an interaction happened; the names say whether it happened
// from the person you think. Both live here because a count that cannot be opened is a number nobody
// can check, and this page exists to be checked.
//
// ⚠ NO JAVASCRIPT. `<details>` opens on click in every browser with nothing loaded, which is the same
// posture the rest of this stack takes about pages that must work on a handset over a slow link.
function people (list) {
  return '<ul class="who-list">' + list.map((r) => {
    const h = (r.author && r.author.handle) || 'someone'
    const u = r.author && r.author.url
    return '<li>' + (u ? '<a href="' + esc(u) + '" rel="nofollow noopener ugc">' + esc(h) + '</a>' : esc(h)) + '</li>'
  }).join('') + '</ul>'
}

function renderActions (r) {
  if (!r.counts) return ''
  const { replies = 0, likes = 0, boosts = 0 } = r.counts
  const bit = (icon, n, list) => {
    if (!n) return '<span class="act dim">' + icon + ' 0</span>'
    if (!list || !list.length) return '<span class="act">' + icon + ' ' + n + '</span>'
    return '<details class="act"><summary>' + icon + ' ' + n + '</summary>' + people(list) + '</details>'
  }
  return '<div class="actions">' +
    bit('💬', replies, null) +
    bit('🔁', boosts, r.boosts) +
    bit('♥', likes, r.likes) +
    '</div>'
}

// Replies sit UNDER the post they answer, oldest first — a thread reads forwards where a timeline
// reads backwards.
function renderReplies (r, host) {
  if (!r.replies || !r.replies.length) return ''
  const kids = [...r.replies].sort((a, b) => String(a.at || '').localeCompare(String(b.at || '')))
  return '<div class="replies">' + kids.map((k) => renderRow(k, host)).join('') + '</div>'
}

export function publicTimeline ({ host = '', rows = [], offset = 0, limit = 50, total = 0, who = null, unaddressable = 0, silent = [] } = {}) {
  const list = rows.length
    ? rows.map((r) => renderRow(r, host)).join('')
    : '<p class="none">Nothing public here yet.</p>'

  const next = offset + rows.length
  const nav = [
    offset > 0 ? '<a href="?offset=' + Math.max(0, offset - limit) + '">← newer</a>' : '',
    // ⛔ Only when there is more. A "load more" that returns an empty page is the page lying about
    // the data behind it.
    next < total ? '<a href="?offset=' + next + '">' + Math.min(limit, total - next) + ' older →</a>' : ''
  ].filter(Boolean).join('')

  return '<!doctype html><meta charset="utf-8"><title>' + esc(who || host) + ' · feed</title>' +
    '<meta name="viewport" content="width=device-width,initial-scale=1">' +
    // ⚠ Not indexed. This is other people's words republished on our domain; letting a crawler make a
    // second permanent copy of them is a decision nobody here has made.
    '<meta name="robots" content="noindex,nofollow">' +
    '<style>' + STYLE + '</style>' +
    '<header><h1>' + esc(who || host) + '</h1>' +
    '<p class="sub">Public posts and interactions. ' +
    'Direct and followers-only messages are never shown here.</p></header>' +
    list +
    (nav ? '<nav>' + nav + '</nav>' : '') +
    '<footer>Showing ' + (rows.length ? (offset + 1) + '–' + next : '0') + ' of ' + total +
    ' public item(s). Newest first, and bounded — the oldest is dropped once the bridge is full.' +
    // ⛔ SAID OUT LOUD. "0 interactions" and "12 interactions we cannot address" are different states
    // and RecordSource already counts the difference; swallowing it here would turn a real defect into
    // an empty-looking feed.
    (unaddressable
      ? '<br>' + unaddressable + ' of our interaction(s) could not be projected to ActivityPub — ' +
        'they act on posts that have no address there. They are shown here, linked to where they live.'
      : '') +
    // ⛔ NAMED, NEVER SILENTLY ABSENT. A peer that is quiet and a peer that is unreachable look
    // identical on a page that just leaves them out, and telling them apart is the difference between
    // "nothing happened" and "we could not see".
    (silent && silent.length
      ? '<br>' + silent.map(esc).join(', ') + ' did not answer in time — this page is rendered without them.'
      : '') +
    '</footer>'
}

export default { publicTimeline }