Changeset 984e0c6 in Klonkt for src


Ignore:
Timestamp:
08/24/2026 04:36:12 PM (2 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
db81e56
Parents:
4d64a4b
Message:

Opsplitsing stap 8 (shaer-drc): de peilingen naar ap-polls.js

Beide kanten van een fediverse-poll verhuizen als twee blokken -- 192
regels, byte-voor-byte. Vreemde polls: parsePoll en de twee
stemhandelingen (voteOnPoll, voteOnRemotePoll, die tot nu toe midden in
de blokkeersectie lagen). Eigen polls: parseOwnPoll, de telling uit de
stembiljetten, de Question-vorm op een note, het innemen van een biljet
en de gebundelde Update(Question).

parsePoll, applyPollToNote en recordPollBallot komen terug de dienst in
voor de inbox, buildNote en de backfill, maar blijven naar buiten toe
prive zoals ze waren. Vier werktuigen gaan via wirePolls naar binnen:
deliverUpdate, rid, movedRefusal en actorUriOf.

Uitvoeroppervlak voor en na identiek gemeten (199 named exports, 180
sleutels op het default-object). Volle suite 1226 groen.
ActivityPubService staat nu op 5207 regels.

Location:
src/services
Files:
1 added
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r4d64a4b r984e0c6  
    9898  followActor, resolveRemoteActor, unfollowActor,
    9999};
     100// Stap 8 (shaer-drc): de peilingen wonen in ap-polls.js. parsePoll,
     101// applyPollToNote en recordPollBallot komen terug voor de inbox, buildNote en
     102// de backfill, maar blijven naar buiten toe prive zoals ze waren.
     103import {
     104  wirePolls,
     105  parsePoll, applyPollToNote, recordPollBallot,
     106  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
     107  voteOnPoll, voteOnRemotePoll,
     108} from './ap-polls.js';
     109export {
     110  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
     111  voteOnPoll, voteOnRemotePoll,
     112};
    100113// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
    101114export { AP_CONTEXT, actorId, noteId, guessMediaType };
     
    17231736}
    17241737
    1725 // Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
    1726 // into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
    1727 // with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
    1728 function parsePoll(o) {
    1729   if (!o || o.type !== 'Question') return null;
    1730   const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
    1731   if (!raw || !raw.length) return null;
    1732   const options = raw.slice(0, 12).map((opt) => ({
    1733     name: String((opt && opt.name) || '').slice(0, 300),
    1734     count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
    1735   })).filter((x) => x.name);
    1736   if (!options.length) return null;
    1737   const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
    1738   const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
    1739   return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
    1740 }
    1741 
    1742 // ── Polls WE host (a local post with a poll) ──────────────────────
    1743 // Parse the poll definition stored on our own post (posts.poll_json). Counts are
    1744 // NOT stored here — they're derived from the poll_votes ballots so a re-render always
    1745 // reflects the authoritative tally.
    1746 export function parseOwnPoll(pollJson) {
    1747   if (!pollJson) return null;
    1748   let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
    1749   if (!d || !Array.isArray(d.options)) return null;
    1750   const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
    1751   if (options.length < 2) return null;
    1752   const endTime = d.endTime || null;
    1753   const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
    1754   return { multiple: !!d.multiple, options, endTime, closed };
    1755 }
    1756 
    1757 // Live tally of a hosted poll from its ballots: per-option counts + unique voters.
    1758 export function pollTally(postId) {
    1759   const counts = {}; let voters = 0;
    1760   try {
    1761     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;
    1762     voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
    1763   } catch { /* table may not exist yet */ }
    1764   return { counts, voters };
    1765 }
    1766 
    1767 // Render-ready view of a hosted poll (options with counts + percentages, totals, state).
    1768 // Voting is fediverse-only, so this is display-only on the site.
    1769 export function ownPollView(post) {
    1770   const poll = parseOwnPoll(post && post.poll_json);
    1771   if (!poll) return null;
    1772   const { counts, voters } = pollTally(post.id);
    1773   const total = Object.values(counts).reduce((a, b) => a + b, 0);
    1774   const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
    1775   const options = poll.options.map((o) => {
    1776     const count = counts[o.name] || 0;
    1777     return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
    1778   });
    1779   return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
    1780 }
    1781 
    1782 // Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
    1783 // status with either media OR a poll (never both), so a poll federates as content +
    1784 // options with no media attachment. oneOf = single choice, anyOf = multiple.
    1785 function applyPollToNote(note, postId, poll) {
    1786   const { counts, voters } = pollTally(postId);
    1787   const opts = poll.options.map((o) => ({
    1788     type: 'Note',
    1789     name: o.name,
    1790     replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
    1791   }));
    1792   note.type = 'Question';
    1793   note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
    1794   if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
    1795   // Once closed, Mastodon expects a `closed` timestamp (the effective end).
    1796   if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
    1797   note.votersCount = voters;
    1798   delete note.attachment;   // media ATTACHMENTS + a poll are mutually exclusive on Mastodon
    1799   // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway
    1800   // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.
    1801   // Deleting it stripped the cover off every boosted poll.
    1802   return note;
    1803 }
    1804 
    1805 // Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
    1806 // `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
    1807 // vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
    1808 // caller must NOT also store it as a reply), false means "not a poll, fall through".
    1809 function recordPollBallot(postId, actorUri, rawChoice) {
    1810   const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
    1811   if (!choice) return { handled: false };
    1812   let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
    1813   const poll = post && parseOwnPoll(post.poll_json);
    1814   if (!poll) return { handled: false };               // not a poll → let the reply logic handle it
    1815   if (poll.closed) return { handled: true };          // voting closed → drop
    1816   if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
    1817   try {
    1818     // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
    1819     // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
    1820     if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
    1821     db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
    1822   } catch { return { handled: true }; }
    1823   schedulePollUpdate(postId);
    1824   return { handled: true };
    1825 }
    1826 
    1827 // Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
    1828 // refresh ~15s out; further votes in that window ride the same pending update (which carries
    1829 // the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
    1830 const _pollUpdTimers = new Map();
    1831 function schedulePollUpdate(postId) {
    1832   if (_pollUpdTimers.has(postId)) return;
    1833   const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
    1834   if (t.unref) t.unref();
    1835   _pollUpdTimers.set(postId, t);
    1836 }
    1837 
    1838 // Push the fresh poll tally (or closed state) to followers as Update(Question).
    1839 export async function deliverPollUpdate(postId) {
    1840   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    1841   if (!base || !postId) return;
    1842   let post, site;
    1843   try {
    1844     post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
    1845     if (!post || !post.poll_json) return;
    1846     site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
    1847   } catch { return; }
    1848   if (site) await deliverUpdate(site, post);
    1849 }
    18501738
    18511739// ── Web push to the owner (docs/webpush-design.md, slice 3) ─────────
     
    49724860
    49734861// True if an actor (or its whole domain) is blocked anywhere on this instance.
    4974 // Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
    4975 // `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
    4976 // Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
    4977 // author's Update(Question) refreshes the authoritative totals when it arrives.
    4978 export async function voteOnPoll(site, questionId, choices) {
    4979   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    4980   if (!base || !site || !site.slug || !questionId) return { error: 'config' };
    4981   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 */ }
    4982   if (!row || !row.poll_json) return { error: 'not_found' };
    4983   let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
    4984   if (poll.closed) return { error: 'closed' };
    4985   if (poll.voted) return { error: 'already' };
    4986   const valid = new Set(poll.options.map((o) => o.name));
    4987   const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
    4988   if (!picks.length) return { error: 'invalid' };
    4989   const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
    4990   const me = actorId(base, site.slug);
    4991   const keys = getOrCreateKeys(site.slug);
    4992   const authorUri = row.author_uri || null;
    4993   const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
    4994   const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
    4995   if (!inbox) return { error: 'unreachable' };
    4996   for (const name of chosen) {
    4997     const nid = `${me}/votes/${Date.now()}-${rid()}`;
    4998     const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
    4999     const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
    5000     deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
    5001   }
    5002   // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
    5003   poll.voted = poll.multiple ? chosen : chosen[0];
    5004   for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
    5005   if (poll.voters != null) poll.voters += 1;
    5006   try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
    5007   return { ok: true };
    5008 }
    5009 
    5010 // Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
    5011 // the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
    5012 // Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
    5013 // by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
    5014 export async function voteOnRemotePoll(site, questionUrl, choices) {
    5015   const _mv = movedRefusal(site, 'poll-vote'); if (_mv) return _mv;
    5016   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    5017   if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
    5018   const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
    5019   if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
    5020   const poll = parsePoll(q);
    5021   if (!poll) return { error: 'not_found' };
    5022   if (poll.closed) return { error: 'closed' };
    5023   const valid = new Set(poll.options.map((o) => o.name));
    5024   const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
    5025   if (!picks.length) return { error: 'invalid' };
    5026   const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
    5027   const authorUri = actorUriOf(q.attributedTo);
    5028   const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
    5029   const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
    5030   if (!inbox) return { error: 'unreachable' };
    5031   const me = actorId(base, site.slug);
    5032   const keys = getOrCreateKeys(site.slug);
    5033   for (const name of chosen) {
    5034     const nid = `${me}/votes/${Date.now()}-${rid()}`;
    5035     const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
    5036     const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
    5037     deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
    5038   }
    5039   return { ok: true };
    5040 }
    50414862
    50424863// Report a remote post or account to its home instance (moderation). Sends the Mastodon-standard
     
    53515172// §5.3-poortwachter, de actorlezer, de id-staart en de twee bezorgers.
    53525173wireFollowing({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox, deliverToActor });
     5174// De peilingen hun vier werktuigen (stap 8): de Update-bezorging voor de
     5175// telling, de id-staart, de verhuisweigering en de attributedTo-lezer.
     5176wirePolls({ deliverUpdate, rid, movedRefusal, actorUriOf });
    53535177
    53545178export default {
Note: See TracChangeset for help on using the changeset viewer.