Ignore:
Timestamp:
07/01/2026 12:53:32 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
bb3f67c
Parents:
731e431
Message:

feat(fediverse): host your own polls (federate as AS2 Question)

A post can carry a poll that federates as an AS2 Question so remote (Mastodon)
followers vote from their own app; votes are tallied server-side and the fresh
counts are pushed back as Update(Question). Voting is fediverse-only; the site
shows live, read-only results. Complements the existing inbound poll support.

  • src/config/database.js — posts.poll_json (our poll definition) + poll_votes table (post_id, actor_uri, choice; UNIQUE) backing the tally + per-actor dedupe.
  • src/services/ActivityPubService.js — parseOwnPoll/pollTally/ownPollView helpers; buildNote emits a Question (oneOf/anyOf + replies.totalItems + endTime/closed + votersCount) for a poll post; handleInbox records a ballot (Note with name + inReplyTo our poll) before the reply path, deduped per actor; a debounced Update(Question) pushes fresh counts to followers; votersCount added to AP_CONTEXT.
  • src/services/Scheduler.js — closeExpiredPolls() marks a poll closed once its endTime passes and pushes the final tally; runs on the existing 60s tick.
  • src/routes/posts.js — parsePollForm() turns the editor fields into poll_json on create/save (a poll with votes is frozen), passes poll_json to the federation hooks, and hands the post page a render-ready ownPollView.
  • src/views/pages/post-edit.ejs — poll section (options, multiple-choice, duration); disabled once the poll has votes.
  • src/views/pages/post.ejs — display-only poll with result bars + voter/close meta.
  • src/services/i18n.js — poll.* + pedit.poll_* strings (nl/en/de).
  • test/polls.test.js — Question shape, tally, percentages, closed state, AS2 term.
  • CHANGELOG(.nl/.de).md — "Create your own polls" under Unreleased.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r731e431 r0403187  
    4242    value: 'schema:value',
    4343    embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
     44    // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
     45    // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
     46    // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
     47    votersCount: 'toot:votersCount',
    4448  },
    4549];
     
    381385  // make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
    382386  if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
     387  // A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
     388  // reuses the note's content/addressing/tags, then swaps the type and strips media.
     389  const ownPoll = parseOwnPoll(post.poll_json);
     390  if (ownPoll) applyPollToNote(note, post.id, ownPoll);
    383391  return note;
    384392}
     
    786794}
    787795
     796// ── Polls WE host (a local post with a poll) ──────────────────────
     797// Parse the poll definition stored on our own post (posts.poll_json). Counts are
     798// NOT stored here — they're derived from the poll_votes ballots so a re-render always
     799// reflects the authoritative tally.
     800export function parseOwnPoll(pollJson) {
     801  if (!pollJson) return null;
     802  let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
     803  if (!d || !Array.isArray(d.options)) return null;
     804  const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
     805  if (options.length < 2) return null;
     806  const endTime = d.endTime || null;
     807  const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
     808  return { multiple: !!d.multiple, options, endTime, closed };
     809}
     810
     811// Live tally of a hosted poll from its ballots: per-option counts + unique voters.
     812export function pollTally(postId) {
     813  const counts = {}; let voters = 0;
     814  try {
     815    for (const r of db.prepare('SELECT choice, COUNT(*) AS n FROM poll_votes WHERE post_id = ? GROUP BY choice').all(postId)) counts[r.choice] = r.n;
     816    voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
     817  } catch { /* table may not exist yet */ }
     818  return { counts, voters };
     819}
     820
     821// Render-ready view of a hosted poll (options with counts + percentages, totals, state).
     822// Voting is fediverse-only, so this is display-only on the site.
     823export function ownPollView(post) {
     824  const poll = parseOwnPoll(post && post.poll_json);
     825  if (!poll) return null;
     826  const { counts, voters } = pollTally(post.id);
     827  const total = Object.values(counts).reduce((a, b) => a + b, 0);
     828  const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
     829  const options = poll.options.map((o) => {
     830    const count = counts[o.name] || 0;
     831    return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
     832  });
     833  return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
     834}
     835
     836// Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
     837// status with either media OR a poll (never both), so a poll federates as content +
     838// options with no media attachment. oneOf = single choice, anyOf = multiple.
     839function applyPollToNote(note, postId, poll) {
     840  const { counts, voters } = pollTally(postId);
     841  const opts = poll.options.map((o) => ({
     842    type: 'Note',
     843    name: o.name,
     844    replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
     845  }));
     846  note.type = 'Question';
     847  note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
     848  if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
     849  // Once closed, Mastodon expects a `closed` timestamp (the effective end).
     850  if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
     851  note.votersCount = voters;
     852  delete note.attachment;   // media + poll are mutually exclusive on Mastodon
     853  delete note.image;
     854  return note;
     855}
     856
     857// Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
     858// `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
     859// vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
     860// caller must NOT also store it as a reply), false means "not a poll, fall through".
     861function recordPollBallot(postId, actorUri, rawChoice) {
     862  const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
     863  if (!choice) return { handled: false };
     864  let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
     865  const poll = post && parseOwnPoll(post.poll_json);
     866  if (!poll) return { handled: false };               // not a poll → let the reply logic handle it
     867  if (poll.closed) return { handled: true };          // voting closed → drop
     868  if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
     869  try {
     870    // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
     871    // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
     872    if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
     873    db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
     874  } catch { return { handled: true }; }
     875  schedulePollUpdate(postId);
     876  return { handled: true };
     877}
     878
     879// Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
     880// refresh ~15s out; further votes in that window ride the same pending update (which carries
     881// the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
     882const _pollUpdTimers = new Map();
     883function schedulePollUpdate(postId) {
     884  if (_pollUpdTimers.has(postId)) return;
     885  const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
     886  if (t.unref) t.unref();
     887  _pollUpdTimers.set(postId, t);
     888}
     889
     890// Push the fresh poll tally (or closed state) to followers as Update(Question).
     891export async function deliverPollUpdate(postId) {
     892  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     893  if (!base || !postId) return;
     894  let post, site;
     895  try {
     896    post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
     897    if (!post || !post.poll_json) return;
     898    site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
     899  } catch { return; }
     900  if (site) await deliverUpdate(site, post);
     901}
     902
    788903// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    789904export async function handleInbox(req, slugParam) {
     
    863978  if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
    864979    const o = act.object;
     980    // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
     981    // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
     982    // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
     983    if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
     984      const seg = postIdFromNoteUrl(o.inReplyTo, base);
     985      if (seg && localPostExists(seg)) {
     986        const rec = recordPollBallot(seg, actorUri, o.name);
     987        if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
     988      }
     989    }
    865990    const tgt = findThreadTarget(o.inReplyTo, base);
    866991    if (tgt && actorUri && !isLocalActor) {
     
    19692094  listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
    19702095  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll,
     2096  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
    19712097  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    19722098  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
Note: See TracChangeset for help on using the changeset viewer.