OverviewFilesCommitsIssuesDocsReleasesPeers

p2pbuilders / web / js / app.js

472 lines · javascript ref:
// app.js — p2pbuilders UI. A Hacker-News-style front end over the reused gossip
// engine. Hash router + event delegation; PoW minting shows live progress; votes
// are reputation-weighted; boards, profiles, follow/block, and subscribable
// blocklists are all here. Runs identically on the PearBrowser bridge and the
// localStorage dev-fallback.

import { createSync } from './sync.js'
import { createIdentity } from './identity.js'
import { createData } from './data.js'
import { Prefs } from './prefs.js'
import { makeValidator, MIN_BITS } from './pow.js'
import { renderMarkdown } from './markdown.js'
import { sortPosts, sortComments, POST_SORTS, COMMENT_SORTS } from './ranking.js'
import { buildCommentTree, sortCommentTree, countDescendants, DEFAULT_BOARD, normalizeBoard } from './model.js'
import { escapeHtml as esc, timeAgo, fmtCount, parseRoute, buildRoute, shortKey, colorFor, debounce } from './util.js'

let sync, identity, data, prefs
let renderToken = 0
let _lastHash = ''
const openReplies = new Set()
const nameCache = new Map()

const $ = (s, r = document) => r.querySelector(s)
const app = () => $('#app')
const nameOf = (pub) => nameCache.get(pub) || ('anon-' + String(pub || '?').slice(0, 6))
async function primeNames (pubs) {
  await Promise.all([...new Set(pubs)].filter(p => p && !nameCache.has(p)).map(async p => nameCache.set(p, await data.nickOf(p))))
}
function domainOf (url) { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return null } }
function safeUrl (u) { return /^(https?:\/\/|hyper:\/\/|pear:\/\/)/i.test(String(u || '')) ? u : null }

// ---- boot -------------------------------------------------------------------
async function boot () {
  identity = createIdentity()
  await identity.ready()
  sync = createSync({ getMe: () => identity.me().pubkey, identity, validate: makeValidator(MIN_BITS) })
  await sync.ready()
  data = createData(sync, identity, { minBits: MIN_BITS })
  refreshPrefs()

  const soft = debounce(() => {
    const a = document.activeElement
    if (a && (/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) || a.isContentEditable)) return
    if (a && a !== document.body && app() && app().contains(a)) return
    route()
  }, 350)
  sync.onChange(soft)
  sync.onChange(() => updateNetStatus())
  setInterval(updateNetStatus, 3000)

  window.addEventListener('hashchange', route)
  document.addEventListener('click', onClick)
  document.addEventListener('submit', onSubmit)
  document.addEventListener('change', onChange)
  document.addEventListener('keydown', e => {
    if (e.key === 'Escape') { const d = $('#userdrop'); if (d && !d.hidden) { d.hidden = true; const b = $('[data-act="toggle-usermenu"]'); if (b) b.setAttribute('aria-expanded', 'false') } }
  })

  renderChrome()
  if (!location.hash) location.hash = '#/'
  route()
}
function refreshPrefs () { prefs = new Prefs(typeof localStorage !== 'undefined' ? localStorage : null, identity.me().pubkey) }

// ---- chrome -----------------------------------------------------------------
function renderChrome () {
  document.body.innerHTML = `
    <header class="topbar">
      <a class="brand" href="#/"><span class="brand-mark">Y</span><span class="brand-name">p2pbuilders</span></a>
      <nav class="topnav">
        <a href="#/">new</a><a href="#/?sort=hot">hot</a><a href="#/?sort=top">top</a>
        <a href="#/boards">boards</a><a href="#/submit">submit</a>
      </nav>
      <form class="search" data-form="search"><input name="q" placeholder="search" autocomplete="off"></form>
      <div class="usermenu" id="usermenu"></div>
    </header>
    <main class="wrap"><section id="app"></section></main>
    <div id="toasts" class="toasts"></div>
    <button id="netstatus" class="netstatus" data-act="netstatus" title="P2P sync status">…</button>`
  renderUserMenu(); updateNetStatus()
}

async function renderUserMenu () {
  const me = identity.me(); await primeNames([me.pubkey])
  const el = $('#usermenu'); if (!el) return
  el.innerHTML = `
    <button class="user-pill" data-act="toggle-usermenu" aria-haspopup="menu" aria-label="Account menu">
      <span class="avatar" style="background:${colorFor(me.pubkey)}"></span><span class="uname">${esc(nameOf(me.pubkey))}</span>
    </button>
    <div class="dropdown" id="userdrop" role="menu" hidden>
      <a role="menuitem" href="#/u/${esc(me.pubkey)}">profile</a>
      <a role="menuitem" href="#/submit">submit</a>
      <a role="menuitem" href="#/boards">boards</a>
      <a role="menuitem" href="#/blocklists">blocklists</a>
      <a role="menuitem" href="#/settings">settings</a>
      ${identity.isDev ? '<div class="dd-sep"></div>' + devSwitcher() : ''}
    </div>`
}
function devSwitcher () {
  const me = identity.me().pubkey
  return '<div class="dd-label">dev: switch user</div>' + identity.listUsers().map(u =>
    `<button class="dd-user ${u.pubkey === me ? 'active' : ''}" data-act="switch-user" data-pub="${esc(u.pubkey)}"><span class="avatar sm" style="background:${colorFor(u.pubkey)}"></span>${esc(u.label || ('anon-' + u.pubkey.slice(0, 6)))}</button>`
  ).join('') + '<button class="dd-user new" data-act="new-user">+ new dev user</button>'
}

async function updateNetStatus () {
  const el = $('#netstatus'); if (!el || !sync) return
  try {
    const s = await sync.status(); const me = identity.me(); const secure = s.secure !== false
    el.className = 'netstatus ' + (String(s.mode || '').includes('bridge') ? 'bridge' : (secure ? 'ok' : 'warn'))
    el.innerHTML = `<b>${esc(s.mode || 'sync')}</b> · ${s.peers != null ? s.peers : 1}p · ${s.viewLength || 0} recs · <span class="mono">${esc((me.pubkey || '').slice(0, 6))}</span>${secure ? '' : ' ⚠'}`
  } catch (e) { el.textContent = 'sync error' }
}

// ---- router -----------------------------------------------------------------
function route () {
  const { path, query } = parseRoute(location.hash)
  const token = ++renderToken
  const guard = h => { if (token === renderToken) app().innerHTML = h }
  if (location.hash !== _lastHash) { _lastHash = location.hash; try { window.scrollTo(0, 0) } catch {} }
  if (path.length === 0) return viewFeed({ board: query.board || 'all', query, guard, token })
  switch (path[0]) {
    case 'b':
      if (path[2] === 'item' && path[3]) return viewItem({ board: path[1], cid: path[3], query, guard, token })
      return viewFeed({ board: path[1], query, guard, token })
    case 'boards': return viewBoards({ guard, token })
    case 'submit': return viewSubmit({ query, guard, token })
    case 'u': return viewProfile({ pub: path[1], guard, token })
    case 'settings': return viewSettings({ guard, token })
    case 'blocklists': return viewBlocklists({ guard, token })
    case 'search': return viewSearch({ query, guard, token })
    default: return guard(notFound())
  }
}

// ---- feed -------------------------------------------------------------------
async function viewFeed ({ board, query, guard, token }) {
  const sort = query.sort || prefs.sort || 'hot'
  guard(skeleton())
  let posts = board === 'all' ? await data.listAllPosts() : await data.listPostsIn(board)
  const blocked = await data.blockedSet(await data.blockedTargets(identity.me().pubkey), prefs.blocklistSubs())
  posts = posts.filter(p => !p.deleted && !blocked.has(p.author) && !prefs.isHidden(p.board + '/' + p.cid))
  posts = await data.withTallies(posts)
  await primeNames(posts.map(p => p.author))
  const counts = await commentCounts(posts)
  if (token !== renderToken) return
  prefs.setSort(sort)
  const ranked = sortPosts(posts, sort)
  const tabs = `<div class="sorttabs">` + POST_SORTS.map(s => `<a class="${s === sort ? 'active' : ''}" href="${buildRoute(board === 'all' ? [] : ['b', board], { ...query, sort: s, board: board === 'all' ? 'all' : undefined })}">${s}</a>`).join('') + `</div>`
  const head = `<div class="feed-head"><h1>${board === 'all' ? 'all builders' : 'b/' + esc(board)}</h1>${tabs}</div>`
  let body
  if (!ranked.length) {
    body = `<div class="empty"><p>No posts yet.</p><a class="btn" href="#/submit">Submit the first one</a> <button class="btn ghost" data-act="seed-demo">load demo</button></div>`
  } else {
    body = `<ol class="feed" start="1">` + ranked.map((p, i) => postRow(p, i + 1, counts)).join('') + `</ol>`
  }
  guard(head + body)
}

function postRow (p, rank, counts) {
  const ref = p.board + '/' + p.cid
  const link = safeUrl(p.url)
  const permalink = buildRoute(['b', p.board, 'item', p.cid])
  const dom = link ? domainOf(link) : null
  const cc = counts ? (counts.get(p.cid) || 0) : 0
  const t = p.tally || { score: 0, myVote: 0 }
  return `<li class="post" data-cid="${esc(p.cid)}" data-board="${esc(p.board)}" data-type="post">
    <span class="rank">${rank}</span>
    <button class="arrow ${t.myVote === 1 ? 'on' : ''}" data-act="vote" data-dir="1" aria-label="upvote">▲</button>
    <div class="post-main">
      <div class="title-line">
        <a class="post-title" href="${link ? esc(link) : permalink}"${link ? ' target="_blank" rel="noopener noreferrer nofollow"' : ''}>${esc(p.title)}</a>
        ${dom ? `<span class="domain">(${esc(dom)})</span>` : ''}
      </div>
      <div class="subline">
        <span class="score">${fmtCount(t.score)} ${Math.abs(t.score) === 1 ? 'point' : 'points'}</span>
        by <a href="#/u/${esc(p.author)}">${esc(nameOf(p.author))}</a>
        <span class="dim">${timeAgo(p.createdAt)}</span>
        <span class="sep">|</span> <a href="#/b/${esc(p.board)}">b/${esc(p.board)}</a>
        <span class="sep">|</span> <a href="${permalink}">${cc} comment${cc === 1 ? '' : 's'}</a>
        <span class="sep">|</span> <button class="linkact" data-act="hide" data-ref="${esc(ref)}">${prefs.isHidden(ref) ? 'unhide' : 'hide'}</button>
        ${p.author === identity.me().pubkey ? `<span class="sep">|</span> <button class="linkact danger" data-act="delete-post">delete</button>` : ''}
      </div>
    </div>
  </li>`
}

async function commentCounts (posts) {
  const m = new Map()
  await Promise.all(posts.map(async p => m.set(p.cid, await sync.count('comment!' + p.cid + '!'))))
  return m
}

// ---- item (post + comments) -------------------------------------------------
async function viewItem ({ board, cid, query, guard, token }) {
  guard(skeleton())
  const post = await data.getPost(board, cid)
  if (!post) return guard(notFound('That post is gone.'))
  const csort = query.csort || 'best'
  const [pw] = await data.withTallies([post])
  let comments = (await data.listComments(cid)).filter(Boolean)
  comments = await data.withTallies(comments)
  await primeNames([post.author, ...comments.map(c => c.author)])
  const { roots } = buildCommentTree(comments)
  const sorted = sortCommentTree(roots, ns => sortComments(ns, csort))
  if (token !== renderToken) return
  const link = safeUrl(post.url); const dom = link ? domainOf(link) : null
  const t = pw.tally
  const mine = post.author === identity.me().pubkey
  const total = roots.reduce((n, r) => n + 1 + countDescendants(r), 0)
  const csortTabs = COMMENT_SORTS.map(s => `<a class="${s === csort ? 'active' : ''}" href="${buildRoute(['b', board, 'item', cid], { csort: s })}">${s}</a>`).join(' ')

  guard(`<article class="item">
    <div class="item-head">
      <button class="arrow ${t.myVote === 1 ? 'on' : ''}" data-cid="${esc(cid)}" data-board="${esc(board)}" data-type="post" data-act="vote" data-dir="1" aria-label="upvote">▲</button>
      <div>
        <h1 class="item-title">${link ? `<a href="${esc(link)}" target="_blank" rel="noopener noreferrer nofollow">${esc(post.title)}</a>` : esc(post.title)} ${dom ? `<span class="domain">(${esc(dom)})</span>` : ''}</h1>
        <div class="subline"><span class="score">${fmtCount(t.score)} points</span> by <a href="#/u/${esc(post.author)}">${esc(nameOf(post.author))}</a> <span class="dim">${timeAgo(post.createdAt)}${post.editedAt ? ' · edited' : ''}</span> <span class="sep">|</span> <a href="#/b/${esc(board)}">b/${esc(board)}</a>
          ${mine ? `<span class="sep">|</span> <button class="linkact danger" data-act="delete-post">delete</button>` : ''}</div>
      </div>
    </div>
    ${post.deleted ? '<p class="removed">[deleted]</p>' : (post.text ? `<div class="item-text md">${renderMarkdown(post.text)}</div>` : '')}
    <form class="composer" data-form="comment" data-board="${esc(board)}" data-post="${esc(cid)}" data-parent="">
      <textarea name="body" rows="4" placeholder="add a comment (markdown) — costs a small proof-of-work"></textarea>
      <div class="composer-actions"><button class="btn" type="submit">comment</button></div>
    </form>
    <div class="comment-bar">${total} comments <span class="csort">sort: ${csortTabs}</span></div>
    <div class="comments">${sorted.length ? sorted.map(n => commentNode(n, post, 0)).join('') : '<p class="dim">No comments yet.</p>'}</div>
  </article>`)
}

function commentNode (node, post, depth) {
  const mine = node.author === identity.me().pubkey
  const t = node.tally || { score: 0, myVote: 0 }
  const cidDom = 'c_' + node.cid
  const body = node.deleted ? '<div class="removed">[deleted]</div>' : `<div class="md">${renderMarkdown(node.body)}</div>`
  const kids = countDescendants(node)
  const replyForm = openReplies.has(node.cid)
    ? `<form class="composer reply" data-form="comment" data-board="${esc(node.board || post.board)}" data-post="${esc(post.cid)}" data-parent="${esc(node.cid)}"><textarea name="body" rows="3" placeholder="reply… (proof-of-work)"></textarea><div class="composer-actions"><button class="btn" type="submit">reply</button><button class="btn ghost" type="button" data-act="cancel-reply" data-cid="${esc(node.cid)}">cancel</button></div></form>`
    : ''
  const children = node.children.length ? `<div class="children">${node.children.map(c => commentNode(c, post, depth + 1)).join('')}</div>` : ''
  return `<div class="comment" id="${cidDom}" data-cid="${esc(node.cid)}" data-board="${esc(node.board || post.board)}" data-type="comment">
    <div class="comment-row">
      <button class="collapse" data-act="collapse" data-target="${cidDom}" aria-label="collapse">[–]</button>
      <button class="arrow sm ${t.myVote === 1 ? 'on' : ''}" data-act="vote" data-dir="1" aria-label="upvote">▲</button>
      <div class="comment-body">
        <div class="chead"><a href="#/u/${esc(node.author)}">${esc(nameOf(node.author))}</a> <span class="dim">${fmtCount(t.score)} pts · ${timeAgo(node.createdAt)}${kids ? ' · ' + kids + ' repl' + (kids === 1 ? 'y' : 'ies') : ''}</span></div>
        ${body}
        <div class="cactions">
          ${!node.deleted ? `<button class="linkact" data-act="reply" data-cid="${esc(node.cid)}">reply</button>` : ''}
          ${mine && !node.deleted ? `<button class="linkact danger" data-act="delete-comment" data-cid="${esc(node.cid)}">delete</button>` : ''}
        </div>
        ${replyForm}
      </div>
    </div>${children}
  </div>`
}

// ---- submit -----------------------------------------------------------------
async function viewSubmit ({ query, guard, token }) {
  const boards = await data.listBoards()
  if (token !== renderToken) return
  const to = normalizeBoard(query.to || DEFAULT_BOARD) || DEFAULT_BOARD
  guard(`<div class="panel"><h1>submit</h1>
    <form data-form="submit-post">
      <label>title<input name="title" maxlength="300" required placeholder="Show P2PB: …"></label>
      <label>url <span class="dim">(optional)</span><input name="url" placeholder="https:// or hyper:// or pear://"></label>
      <label>text <span class="dim">(optional, markdown)</span><textarea name="text" rows="6"></textarea></label>
      <label>board<select name="board">${boards.map(b => `<option value="${esc(b.name)}" ${b.name === to ? 'selected' : ''}>b/${esc(b.name)}</option>`).join('')}</select></label>
      <div class="form-actions"><button class="btn" type="submit">submit</button> <a class="btn ghost" href="#/">cancel</a></div>
      <p class="dim small">Submitting mints a proof-of-work (a second or two) — the permissionless spam gate. No account needed.</p>
    </form></div>`)
}

// ---- boards -----------------------------------------------------------------
async function viewBoards ({ guard, token }) {
  guard(skeleton())
  const boards = await data.listBoards()
  await Promise.all(boards.map(async b => { b._count = await sync.count('post!' + b.name + '!') }))
  if (token !== renderToken) return
  boards.sort((a, b) => (b._count || 0) - (a._count || 0))
  guard(`<div class="feed-head"><h1>boards</h1></div>
    <div class="panel"><h2>create a board</h2>
      <form data-form="create-board">
        <label>name <span class="dim">b/</span><input name="name" maxlength="24" placeholder="showp2pb" required><small class="hint">2–24 chars: a–z 0–9 _ · creating a board mints a proof-of-work</small></label>
        <label>description<input name="description" maxlength="300"></label>
        <div class="form-actions"><button class="btn" type="submit">create</button></div>
      </form></div>
    <ul class="board-list">${boards.map(b => `<li><a href="#/b/${esc(b.name)}">b/${esc(b.name)}</a> <span class="dim">${fmtCount(b._count || 0)} posts${b.description ? ' · ' + esc(b.description) : ''}</span></li>`).join('')}</ul>`)
}

// ---- profile ----------------------------------------------------------------
async function viewProfile ({ pub, guard, token }) {
  guard(skeleton())
  const me = identity.me(); const mine = pub === me.pubkey
  const profile = await data.getProfile(pub)
  const act = await data.userActivity(pub)
  const inputs = await data.weightInputsFor(pub)
  const myFollowing = mine ? [] : await data.following(me.pubkey)
  await primeNames([pub])
  if (token !== renderToken) return
  const items = [...act.posts.map(p => ({ k: 'post', t: p.createdAt, p })), ...act.comments.map(c => ({ k: 'comment', t: c.createdAt, c }))].sort((a, b) => b.t - a.t).slice(0, 60)
  const isFollowing = myFollowing.includes(pub)
  const feed = items.length ? items.map(it => it.k === 'post'
    ? `<li class="act"><span class="atag">post</span> <a href="${buildRoute(['b', it.p.board, 'item', it.p.cid])}">${esc(it.p.title)}</a> <span class="dim">in b/${esc(it.p.board)} · ${timeAgo(it.p.createdAt)}</span></li>`
    : `<li class="act"><span class="atag">comment</span> on <a href="${buildRoute(['b', it.c.board || 'front', 'item', it.c.postCid])}">${esc(it.c.postTitle || 'a post')}</a> <span class="dim">${timeAgo(it.c.createdAt)}</span><div class="md small">${renderMarkdown(it.c.body)}</div></li>`).join('') : '<li class="dim">No activity yet.</li>'
  guard(`<div class="profile-head">
      <span class="avatar lg" style="background:${colorFor(pub)}"></span>
      <div><h1>${esc(nameOf(pub))}</h1><div class="dim mono small">${esc(shortKey(pub, 10))}</div>
        ${profile && profile.bio ? `<p class="bio">${esc(profile.bio)}</p>` : ''}
        <div class="rep">received upvotes: <b>${fmtCount(inputs[1])}</b> · age: <b>${Math.floor(inputs[0])}d</b> · vote weight: <b>${(Math.log2(1 + inputs[0]) * Math.sqrt(1 + inputs[1]) / 50).toFixed(2)}</b></div>
        ${mine ? '<a class="btn ghost sm" href="#/settings">edit profile</a>' : `<button class="btn ghost sm" data-act="follow" data-pub="${esc(pub)}">${isFollowing ? 'unfollow' : 'follow'}</button> <button class="btn ghost sm" data-act="block" data-pub="${esc(pub)}">block</button>`}
      </div></div>
    <h2 class="section-title">activity</h2><ul class="activity">${feed}</ul>`)
}

// ---- settings ---------------------------------------------------------------
async function viewSettings ({ guard, token }) {
  const me = identity.me(); const p = await data.getProfile(me.pubkey); const st = await data.status()
  if (token !== renderToken) return
  guard(`<div class="panel"><h1>settings</h1>
    <h2>profile</h2>
    <form data-form="profile">
      <label>nickname<input name="nick" maxlength="24" value="${esc((p && p.nick) || '')}" placeholder="pick a handle"></label>
      <label>bio<textarea name="bio" rows="3" maxlength="300">${esc((p && p.bio) || '')}</textarea></label>
      <div class="form-actions"><button class="btn" type="submit">save</button></div>
    </form>
    <h2>identity</h2><p class="mono small">${esc(me.pubkey)}</p>
    <h2>network</h2><ul class="kv"><li><span>mode</span><b>${st.mode}${st.secure === false ? ' (insecure)' : ''}</b></li><li><span>records</span><b>${fmtCount(st.viewLength || 0)}</b></li><li><span>peers</span><b>${st.peers != null ? st.peers : 1}</b></li></ul>
    ${identity.isDev ? `<h2>dev tools</h2><p class="dim small">Open multiple tabs to act as multiple peers.</p><div class="form-actions"><button class="btn ghost" data-act="seed-demo">load demo</button> <button class="btn ghost danger" data-act="wipe">wipe local data</button></div>` : ''}
  </div>`)
}

// ---- blocklists -------------------------------------------------------------
async function viewBlocklists ({ guard, token }) {
  guard(skeleton())
  const lists = await data.listBlocklists()
  await primeNames(lists.map(l => l.author))
  if (token !== renderToken) return
  const me = identity.me().pubkey
  const mine = lists.find(l => l.author === me)
  guard(`<div class="feed-head"><h1>blocklists</h1></div>
    <div class="panel"><h2>publish your blocklist</h2>
      <form data-form="blocklist"><label>blocked pubkeys <span class="dim">(comma/space separated)</span><textarea name="list" rows="3" placeholder="pubkey1 pubkey2 …">${mine ? esc((mine.list || []).join(' ')) : ''}</textarea></label><div class="form-actions"><button class="btn" type="submit">publish</button></div></form>
      <p class="dim small">Others can subscribe to your list to hide those keys. Curate responsibly.</p></div>
    <h2 class="section-title">curators</h2>
    <ul class="board-list">${lists.length ? lists.map(l => `<li><a href="#/u/${esc(l.author)}">${esc(nameOf(l.author))}</a> <span class="dim">${(l.list || []).length} keys · v${l.version || 1}</span> <button class="btn ghost sm" data-act="sub-blocklist" data-pub="${esc(l.author)}">${prefs.isSubscribedBlocklist(l.author) ? 'subscribed' : 'subscribe'}</button></li>`).join('') : '<li class="dim">No published blocklists yet.</li>'}</ul>`)
}

// ---- search -----------------------------------------------------------------
async function viewSearch ({ query, guard, token }) {
  const q = (query.q || '').trim().toLowerCase()
  guard(skeleton())
  if (!q) return guard('<div class="empty"><p>Type a query above.</p></div>')
  let posts = (await data.listAllPosts()).filter(p => !p.deleted && (p.title + ' ' + (p.text || '') + ' ' + (p.url || '')).toLowerCase().includes(q))
  posts = await data.withTallies(posts); await primeNames(posts.map(p => p.author))
  const counts = await commentCounts(posts)
  if (token !== renderToken) return
  guard(`<div class="feed-head"><h1>results for "${esc(q)}"</h1></div><ol class="feed">${posts.length ? sortPosts(posts, 'top').map((p, i) => postRow(p, i + 1, counts)).join('') : '<p class="dim">No matches.</p>'}</ol>`)
}

// ---- helpers ----------------------------------------------------------------
function skeleton () { return '<div class="feed-head"><h1>&nbsp;</h1></div><ol class="feed">' + '<li class="post skel"><span class="rank"></span><span class="sk"></span></li>'.repeat(5) + '</ol>' }
function notFound (m) { return `<div class="empty"><h3>not found</h3><p>${esc(m || 'Nothing here.')}</p><a class="btn" href="#/">home</a></div>` }
function toast (msg, kind = 'ok') { const r = $('#toasts'); if (!r) return; const e = document.createElement('div'); e.className = 'toast ' + kind; e.textContent = msg; r.appendChild(e); setTimeout(() => { e.classList.add('out'); setTimeout(() => e.remove(), 300) }, 2800) }
function parseScore (s) { s = String(s).trim(); const m = parseFloat(s); return /k$/i.test(s) ? m * 1000 : (parseInt(s, 10) || 0) }

// ---- events -----------------------------------------------------------------
async function onClick (e) {
  const t = e.target.closest('[data-act]')
  if (!t) { const d = $('#userdrop'); if (d && !e.target.closest('#usermenu')) d.hidden = true; return }
  const act = t.dataset.act
  try {
    switch (act) {
      case 'vote': return void await onVote(t)
      case 'hide': { prefs.toggleHidden(t.dataset.ref); route(); return }
      case 'collapse': { const n = document.getElementById(t.dataset.target); if (n) { const c = n.classList.toggle('collapsed'); t.textContent = c ? '[+]' : '[–]' } return }
      case 'reply': { openReplies.add(t.dataset.cid); route(); return }
      case 'cancel-reply': { openReplies.delete(t.dataset.cid); route(); return }
      case 'toggle-usermenu': { const d = $('#userdrop'); if (d) { d.hidden = !d.hidden; t.setAttribute('aria-expanded', String(!d.hidden)) } return }
      case 'netstatus': return void updateNetStatus()
      case 'switch-user': { identity.switchUser(t.dataset.pub); if (sync.announce) await sync.announce(); refreshPrefs(); nameCache.clear(); await renderUserMenu(); route(); toast('switched'); return }
      case 'new-user': { const n = prompt('dev user name:', 'anon' + Math.floor(Math.random() * 999)); if (!n) return; await identity.createUser(n); if (sync.announce) await sync.announce(); refreshPrefs(); nameCache.clear(); await renderUserMenu(); route(); return }
      case 'delete-post': { const el = t.closest('[data-board]'); if (confirm('Delete this post?')) { await data.deletePost(el.dataset.board, el.dataset.cid); toast('deleted'); location.hash = '#/' } return }
      case 'delete-comment': { const el = t.closest('.comment'); const { path } = parseRoute(location.hash); if (confirm('Delete this comment?')) { await data.deleteComment(path[3], t.dataset.cid); toast('deleted'); route() } return }
      case 'follow': { const cur = (await data.following(identity.me().pubkey)).includes(t.dataset.pub); await (cur ? data.unfollow(t.dataset.pub) : data.follow(t.dataset.pub)); toast(cur ? 'unfollowed' : 'followed'); route(); return }
      case 'block': { if (confirm('Block ' + nameOf(t.dataset.pub) + '? (also publish publicly?)')) { await data.block(t.dataset.pub, true); toast('blocked'); route() } return }
      case 'sub-blocklist': { const now = prefs.toggleBlocklist(t.dataset.pub); toast(now ? 'subscribed' : 'unsubscribed'); route(); return }
      case 'seed-demo': return void await seedDemo()
      case 'wipe': { if (confirm('Wipe ALL local data?')) { try { Object.keys(localStorage).filter(k => k.startsWith('p2pb:')).forEach(k => localStorage.removeItem(k)); sessionStorage.clear() } catch {} location.reload() } return }
    }
  } catch (err) { toast(err.message || String(err), 'error') }
}

function onChange (e) {
  if (e.target.matches('.sorttabs')) {}
}

async function onVote (t) {
  const box = t.closest('[data-cid]')
  const cid = box.dataset.cid, type = box.dataset.type
  const cur = t.classList.contains('on') ? 1 : 0
  const next = cur === 1 ? 0 : 1
  const scoreEl = box.querySelector('.score')
  const paint = (on) => { t.classList.toggle('on', on === 1) }
  paint(next)
  if (scoreEl) { const base = parseScore(scoreEl.textContent) - cur; scoreEl.textContent = fmtCount(base + next) + (Math.abs(base + next) === 1 ? ' point' : ' points') }
  try { await data.vote(cid, type, next) } catch (err) { paint(cur); throw err }
}

async function onSubmit (e) {
  const form = e.target.closest('form[data-form]'); if (!form) return
  e.preventDefault()
  const f = form.dataset.form; const fd = new FormData(form)
  if (form.dataset.busy) return
  const btn = form.querySelector('button[type="submit"]')
  const setBusy = (label) => { form.dataset.busy = '1'; if (btn) { btn.dataset.label = btn.dataset.label || btn.textContent; btn.disabled = true; btn.textContent = label } }
  const clearBusy = () => { delete form.dataset.busy; if (btn && document.contains(btn)) { btn.disabled = false; btn.textContent = btn.dataset.label } }
  const onProgress = (n) => { if (btn) btn.textContent = 'minting PoW… ' + fmtCount(n) }
  try {
    if (f === 'search') { const q = (fd.get('q') || '').trim(); if (q) location.hash = buildRoute(['search'], { q }); return }
    if (f === 'submit-post') {
      setBusy('minting PoW…')
      const p = await data.submitPost({ board: fd.get('board'), title: fd.get('title'), url: fd.get('url'), text: fd.get('text'), onProgress })
      toast('posted'); location.hash = buildRoute(['b', p.board, 'item', p.cid]); return
    }
    if (f === 'create-board') {
      setBusy('minting PoW…')
      const b = await data.createBoard({ name: fd.get('name'), description: fd.get('description'), onProgress })
      toast('created b/' + b.name); location.hash = '#/b/' + b.name; return
    }
    if (f === 'comment') {
      setBusy('minting PoW…')
      const parent = form.dataset.parent || null
      await data.addComment({ postCid: form.dataset.post, board: form.dataset.board, parentCid: parent, body: fd.get('body'), onProgress })
      if (parent) openReplies.delete(parent)
      toast('commented'); route(); return
    }
    if (f === 'profile') { await data.setProfile({ nick: fd.get('nick'), bio: fd.get('bio') }); data.invalidateProfile(identity.me().pubkey); nameCache.delete(identity.me().pubkey); await renderUserMenu(); toast('saved'); route(); return }
    if (f === 'blocklist') { const list = (fd.get('list') || '').split(/[\s,]+/).filter(Boolean); await data.publishBlocklist(list); toast('blocklist published'); route(); return }
  } catch (err) { toast(err.message || String(err), 'error') }
  finally { clearBusy() }
}

async function seedDemo () {
  toast('seeding…')
  const d = data
  try { await d.createBoard({ name: 'showp2pb' }) } catch {}
  const posts = [
    { board: 'front', title: 'peerit — a peer-to-peer Reddit running in PearBrowser', url: 'hyper://peerit' },
    { board: 'front', title: 'Ask P2PB: how do you keep a Hyperdrive online 24/7?', text: 'HiveRelay pins it for you — dumb always-on storage. What else are people using?' },
    { board: 'showp2pb', title: 'Show P2PB: this very app, ported to the browser', text: 'Same gossip engine as peerit. Posts are PoW-gated and reputation-weighted.' }
  ]
  let first = null
  for (const p of posts) { try { const r = await d.submitPost(p); first = first || r } catch (e) { console.error(e) } }
  if (first) { const c = await d.addComment({ postCid: first.cid, board: first.board, body: 'replicated to you from a peer — no server involved.' }); await d.vote(first.cid, 'post', 1); void c }
  toast('demo ready'); location.hash = '#/'; route()
}

// A boot failure must never strand the splash screen silently.
function bootSafe () {
  boot().catch((err) => {
    console.error('boot failed:', err)
    const sub = document.querySelector('.boot-sub')
    if (sub) sub.innerHTML = `failed to start: ${esc(err && err.message ? err.message : String(err))}<br>see the browser console for details.`
  })
}

if (typeof window !== 'undefined') window.__p2pb = { get data () { return data }, get sync () { return sync }, route }
if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootSafe); else bootSafe() }
export { boot }