OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / mentions.js

123 lines · javascript ref:
// Turning `@alice0` into a mention.
//
// ⛔ `markdown-strip.md` §3.4: "a bare @alice with no target is plain text with no facet — never a
// guess". That rule constrains the CODEC, which has no registry and genuinely cannot know who was
// meant. It does not forbid RESOLVING one where the answer is known — the namespace says which feed
// `alice0` names, and the feed's own founder says whether it agrees. Resolution is not a guess, and
// the codec still never does it.
//
// ⛔ This lives in the daemon rather than in a surface. The CLI writes posts, a phone will write
// posts, and a feature that exists in one client is not the feature.

/**
 * ⛔ A NAME or a handle. The first version required the trailing number and so matched `@alice0`
 * and never `@alice` — and nobody types the number. It exists so a name is never scarce; a person
 * types the name. Every mention anybody actually wrote was left as plain text.
 *
 * ⚠ `(^|[^\w@/])` keeps an EMAIL from matching: `alice@alice0.example` has a word character before
 * the @, and a URL path has a slash.
 */
const AT = /(^|[^\w@/])@([a-z][a-z0-9._-]{0,31})\b/gi

/**
 * Segments of `md` that are PROSE, with code left out.
 *
 * ⛔ A regex over the whole body rewrites inside code, and a code sample is exactly where an @ is
 * most likely to appear without meaning a person. Fenced blocks and inline spans are skipped whole.
 */
function prose (md) {
  const out = []
  let i = 0
  // Fences first, then inline spans inside what is left.
  const fence = /```[\s\S]*?```|~~~[\s\S]*?~~~/g
  let m
  while ((m = fence.exec(md)) !== null) {
    if (m.index > i) out.push([i, m.index])
    i = m.index + m[0].length
  }
  if (i < md.length) out.push([i, md.length])

  const final = []
  for (const [start, end] of out) {
    const chunk = md.slice(start, end)
    let j = 0
    const span = /`[^`\n]*`/g
    let s
    while ((s = span.exec(chunk)) !== null) {
      if (s.index > j) final.push([start + j, start + s.index])
      j = s.index + s[0].length
    }
    if (j < chunk.length) final.push([start + j, end])
  }
  return final
}

// Is this @ already inside a markdown link — either its label or its target?
// ⛔ Without this, saving a post twice nests the link inside itself.
function insideLink (md, at) {
  const before = md.lastIndexOf('[', at)
  if (before !== -1) {
    const close = md.indexOf(']', before)
    if (close !== -1 && close > at && md[close + 1] === '(') return true
  }
  const paren = md.lastIndexOf('(', at)
  if (paren !== -1 && md[paren - 1] === ']') {
    const end = md.indexOf(')', paren)
    if (end !== -1 && end > at) return true
  }
  return false
}

/**
 * Rewrite bare handles into mentions.
 *
 * `resolve(token)` returns `{ root, verdict }` or null. The token is a HANDLE (`alice0`) or a bare
 * NAME (`alice`); the caller decides which, because only it holds the namespace.
 *
 * ⚠ A bare name resolves only when exactly ONE confirmed claimant holds it. NAMING.md §3.2 — an
 * unresolvable name fails visibly rather than having a choice guessed for it — and picking "the
 * first alice" would be that guess wearing a helpful face. One candidate is not a guess.
 *
 * ⛔ CONFIRMED only. A handle claimed at somebody else's feed key is the rename attack, and turning
 * one into a link here would be that attack arriving through the composer. `unheld` is left alone
 * too: we cannot check a feed we do not hold, and linking unchecked is how a check becomes
 * decorative.
 */
export async function linkMentions (body, resolve) {
  const md = String(body == null ? '' : body)
  if (!md.includes('@')) return md

  const regions = prose(md)
  const inProse = (idx) => regions.some(([s, e]) => idx >= s && idx < e)

  // ⚠ One lookup per DISTINCT handle. A post naming somebody ten times asks once.
  const wanted = new Set()
  for (const m of md.matchAll(AT)) {
    const at = m.index + m[1].length
    if (!inProse(at) || insideLink(md, at)) continue
    wanted.add(m[2].toLowerCase())
  }
  if (!wanted.size) return md

  const found = new Map()
  for (const h of wanted) {
    let r = null
    try { r = await resolve(h) } catch {}
    if (r && r.verdict === 'confirmed' && /^[0-9a-f]{64}$/i.test(r.root || '')) found.set(h, r.root)
  }
  if (!found.size) return md

  let out = ''
  let last = 0
  for (const m of md.matchAll(AT)) {
    const at = m.index + m[1].length
    const root = found.get(m[2].toLowerCase())
    if (!root || !inProse(at) || insideLink(md, at)) continue
    out += md.slice(last, at) + '[@' + m[2] + '](favela://' + root + ')'
    last = at + 1 + m[2].length
  }
  return out + md.slice(last)
}

export default { linkMentions }