Ignore:
Timestamp:
07/19/2026 10:48:15 PM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
ff3b8ce
Parents:
5190152
git-author:
Robin <roboburr@…> (07/19/2026 10:47:10 PM)
git-committer:
Robin <roboburr@…> (07/19/2026 10:48:15 PM)
Message:

Feature: mentions bar with conversation partners (klonkt-demo-u02)

Implicit mentions leave the text and become an editable "To:" bar above the
reply editor: the parent author plus the thread's ancestor authors as chips.
Removing a chip stops addressing that partner (no mention anchor, no Mention
tag, no inbox ping); explicit @mentions typed in the text keep working via the
existing resolveMentionsInText path.

  • getInteractions: each thread node gets "participants" (author + ancestor chain via the byId map, own nodes skipped, deduped, capped at 8) and carries actor_uri now.
  • reply-editor partial: the bar renders server-side with a hidden "mentions" JSON field carrying the full list, so the no-JS path addresses everyone; the JS removes chips and mirrors the remaining list into the field.
  • deliverReply({mentions}): undefined = legacy parent-only behavior; an array (possibly empty) = the kept list drives the mention prefix, the Mention tags (mentionTags reads the content anchors) and the per-actor inbox pings. to_actor/to_handle follow the kept list (parent when kept, else the first chip, else null -> the note addresses Public only).
  • deliverOutboxUpdate: an edit reuses the OLD content's leading mention anchors instead of rebuilding just to_actor, so co-mentions survive edits (legacy rows fall back as before).
  • Interact page passes the target author as the single chip. Full-screen mobile keeps the top bar above the mentions bar (flex order).

4 new tests (participant chains, multi-chip tags + to_actor, empty bar goes
Public-only, co-mentions survive edits); 97 green. Browser-verified: bar shows
@bob + @alice on a nested reply, removing @alice updates the hidden field, the
sent reply mentions and addresses only @bob; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r5190152 re9c9ae1  
    822822    nodes.push({
    823823      noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
     824      actor_uri: r.actor_uri,
    824825      actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
    825826      actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
     
    839840
    840841  const byId = new Map(nodes.map((n) => [n.noteId, n]));
     842  // Conversation partners per node (u02, the reply editor's mentions bar): the
     843  // node's author plus the ancestor authors up the chain. Our own nodes are
     844  // skipped (we do not mention ourselves), deduped by actor, capped at 8.
     845  for (const n of nodes) {
     846    const seen = new Set();
     847    const list = [];
     848    let cur = n, guard = 0;
     849    while (cur && guard++ < 12 && list.length < 8) {
     850      if (!cur.mine && cur.actor_uri && !seen.has(cur.actor_uri)) {
     851        seen.add(cur.actor_uri);
     852        list.push({
     853          uri: cur.actor_uri,
     854          url: cur.actor_url || cur.actor_uri,
     855          handle: cur.actor_handle || deriveHandle(cur.actor_uri),
     856        });
     857      }
     858      cur = cur.parent ? byId.get(cur.parent) : null;
     859    }
     860    n.participants = list;
     861  }
    841862  const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
    842863  const tops = [];
     
    18721893// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
    18731894// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
    1874 export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments }) {
     1895export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments, mentions }) {
    18751896  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    18761897  // Rich replies: `html` is the reply editor's HTML (sanitized here); `text` is
     
    18881909  if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich && !media.length)) return null;
    18891910  const me = actorId(base, site.slug);
     1911  // u02, the mentions bar: `mentions` undefined = legacy behavior (mention the
     1912  // parent author). An ARRAY (possibly empty) = the kept conversation partners
     1913  // exactly as the bar shows them; the mention prefix, the Mention tags (via
     1914  // mentionTags over the content) and the delivery targets all follow it.
     1915  const kept = Array.isArray(mentions)
     1916    ? mentions
     1917      .filter((m) => m && typeof m.uri === 'string' && /^https?:\/\//i.test(m.uri))
     1918      .slice(0, 8)
     1919      .map((m) => ({
     1920        uri: m.uri,
     1921        url: (typeof m.url === 'string' && /^https?:\/\//i.test(m.url)) ? m.url : m.uri,
     1922        handle: String(m.handle || deriveHandle(m.uri)).slice(0, 120),
     1923      }))
     1924    : null;
     1925  const mentionAnchor = (uri, url, h) => {
     1926    const disp = h && h[0] === '@' ? h : '@' + (h || '');
     1927    return `<a href="${escHtml(url || uri)}" class="u-url mention" data-actor="${escHtml(uri)}">${escHtml(disp)}</a> `;
     1928  };
    18901929  const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
    1891   const dispHandle = handle && handle[0] === '@' ? handle : '@' + (handle || '');
    1892   const mention = parent.actor_uri
    1893     ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention" data-actor="${escHtml(parent.actor_uri)}">${escHtml(dispHandle)}</a> ` : '';
     1930  const mention = kept
     1931    ? kept.map((k) => mentionAnchor(k.uri, k.url, k.handle)).join('')
     1932    : (parent.actor_uri ? mentionAnchor(parent.actor_uri, parent.actor_url, handle) : '');
     1933  // Who the stored reply is "to": the parent when kept, else the first kept chip.
     1934  const parentKept = !kept || kept.some((k) => k.uri === parent.actor_uri);
     1935  const toActorUri = parentKept ? (parent.actor_uri || null) : (kept[0] ? kept[0].uri : null);
     1936  const toHandle = parentKept ? handle : (kept[0] ? kept[0].handle : null);
    18941937  let content;
    18951938  let mres;
     
    19201963  if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
    19211964  const id = crypto.randomUUID();
    1922   iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content, replyLang, mediaJson);
     1965  iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, toActorUri, toHandle, content, replyLang, mediaJson);
    19231966  const row = iStmts().getO.get(id);
    19241967  const note = buildReplyNote(base, site, row);
     
    19311974  const keyId = `${me}#main-key`;
    19321975  const inboxes = new Set();
    1933   if (parent.actor_uri) {
    1934     const a = await fetchActor(parent.actor_uri).catch(() => null);
     1976  // Everyone the mentions bar kept gets pinged; legacy path = the parent only.
     1977  const mentionTargets = kept ? kept.map((k) => k.uri) : (parent.actor_uri ? [parent.actor_uri] : []);
     1978  for (const uri of mentionTargets) {
     1979    const a = await fetchActor(uri).catch(() => null);
    19351980    if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
    19361981  }
     
    20922137  const _h = row.to_handle || deriveHandle(row.to_actor);
    20932138  const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
    2094   const mention = row.to_actor
    2095     ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '';
     2139  // An edit must not drop co-mentions (u02): reuse the OLD content's leading
     2140  // mention anchors (the bar's kept list at send time) when present; only fall
     2141  // back to rebuilding the single to_actor mention for legacy rows.
     2142  const oldPrefix = (String(row.content || '')
     2143    .match(/^\s*(?:<p[^>]*>)?\s*((?:<a\b[^>]*class="u-url mention"[^>]*>\s*@[^<]+<\/a>[\s ]*)+)/i) || [])[1] || '';
     2144  const mention = oldPrefix || (row.to_actor
     2145    ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '');
    20962146  let content;
    20972147  let mres;
Note: See TracChangeset for help on using the changeset viewer.