Ignore:
Timestamp:
07/01/2026 07:38:33 AM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
55a73f0
Parents:
f70e010
Message:

feat(fediverse): show + vote on remote polls (Question) in the News feed

Inbound ActivityPub polls, phase 1. A Question (the Mastodon-standard poll)
from a followed account is now cached and rendered in the News feed with its
options, vote counts and close state, and the owner can vote — a ballot is a
Create(Note) carrying only the chosen option's name + inReplyTo the Question,
addressed to the poll's author (the fediverse-standard vote). The author's
Update(Question) refreshes the authoritative counts; our own vote is preserved.

  • config/database.js — ap_timeline.poll_json column
  • services/ActivityPubService.js — parsePoll(); handle Create/Update(Question); voteOnPoll() sends the ballot + optimistic local tally
  • routes/posts.js — parse poll_json for the feed; POST /news/vote (owner-only)
  • views/pages/news.ejs — poll UI (votable options / result bars) + styles
  • services/i18n.js — poll.vote/votes/closed (nl/en/de)

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rf70e010 r6053c6c  
    769769}
    770770
     771// Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
     772// into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
     773// with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
     774function parsePoll(o) {
     775  if (!o || o.type !== 'Question') return null;
     776  const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
     777  if (!raw || !raw.length) return null;
     778  const options = raw.slice(0, 12).map((opt) => ({
     779    name: String((opt && opt.name) || '').slice(0, 300),
     780    count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
     781  })).filter((x) => x.name);
     782  if (!options.length) return null;
     783  const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
     784  const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
     785  return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
     786}
     787
    771788// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    772789export async function handleInbox(req, slugParam) {
     
    844861
    845862  // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
    846   if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
     863  if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
    847864    const o = act.object;
    848865    const tgt = findThreadTarget(o.inReplyTo, base);
     
    869886        }
    870887        const media = JSON.stringify(_atts);
     888        const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
    871889        // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
    872890        // incoming posts to the fediverse — that flooded followers. Boosting to the
     
    875893        for (const s of subs) {
    876894          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
     895          if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
    877896        }
    878897        console.log('[AP] timeline +', actorUri, 'x' + subs.length);
     
    885904  // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
    886905  // edit A's note (the signature gate guarantees claimedActor == the verified signer).
    887   if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
     906  if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
    888907    const o = act.object;
    889908    if (o.id && claimedActor) {
     
    897916          .run(html, media, o.sensitive ? 1 : 0, o.summary || null, o.url || null, o.id, claimedActor);
    898917        if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
     918        // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
     919        // site keeps its own `voted` state while the counts/closed update to the new totals.
     920        const poll = parsePoll(o);
     921        if (poll) {
     922          const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
     923          const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
     924          for (const rw of rows) {
     925            let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
     926            upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
     927          }
     928        }
    899929      } catch { /* ignore */ }
    900930      // If this note is a cached fediverse reply on one of our posts, refresh its text too.
     
    18451875
    18461876// True if an actor (or its whole domain) is blocked anywhere on this instance.
     1877// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
     1878// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
     1879// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
     1880// author's Update(Question) refreshes the authoritative totals when it arrives.
     1881export async function voteOnPoll(site, questionId, choices) {
     1882  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     1883  if (!base || !site || !site.slug || !questionId) return { error: 'config' };
     1884  let row; try { row = db.prepare('SELECT author_uri, poll_json FROM ap_timeline WHERE id = ? AND slug = ? LIMIT 1').get(questionId, site.slug); } catch { /* ignore */ }
     1885  if (!row || !row.poll_json) return { error: 'not_found' };
     1886  let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
     1887  if (poll.closed) return { error: 'closed' };
     1888  if (poll.voted) return { error: 'already' };
     1889  const valid = new Set(poll.options.map((o) => o.name));
     1890  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
     1891  if (!picks.length) return { error: 'invalid' };
     1892  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
     1893  const me = actorId(base, site.slug);
     1894  const keys = getOrCreateKeys(site.slug);
     1895  const authorUri = row.author_uri || null;
     1896  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
     1897  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
     1898  if (!inbox) return { error: 'unreachable' };
     1899  for (const name of chosen) {
     1900    const nid = `${me}/votes/${Date.now()}-${rid()}`;
     1901    const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
     1902    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
     1903    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
     1904  }
     1905  // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
     1906  poll.voted = poll.multiple ? chosen : chosen[0];
     1907  for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
     1908  if (poll.voters != null) poll.voters += 1;
     1909  try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
     1910  return { ok: true };
     1911}
     1912
    18471913export function isBlockedAny(actorUri) {
    18481914  if (!actorUri) return false;
     
    18991965  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    19001966  listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
    1901   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction,
     1967  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll,
    19021968  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    19031969  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
Note: See TracChangeset for help on using the changeset viewer.