Changeset 0403187 in Klonkt for src/services/ActivityPubService.js
- Timestamp:
- 07/01/2026 12:53:32 PM (2 months ago)
- Branches:
- main
- Children:
- bb3f67c
- Parents:
- 731e431
- File:
-
- 1 edited
-
src/services/ActivityPubService.js (modified) (5 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r731e431 r0403187 42 42 value: 'schema:value', 43 43 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', 44 48 }, 45 49 ]; … … 381 385 // make it JSON-LD-clean with a context term, otherwise it degrades to the player card. 382 386 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); 383 391 return note; 384 392 } … … 786 794 } 787 795 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. 800 export 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. 812 export 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. 823 export 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. 839 function 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". 861 function 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. 882 const _pollUpdTimers = new Map(); 883 function 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). 891 export 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 788 903 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox. 789 904 export async function handleInbox(req, slugParam) { … … 863 978 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) { 864 979 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 } 865 990 const tgt = findThreadTarget(o.inReplyTo, base); 866 991 if (tgt && actorUri && !isLocalActor) { … … 1969 2094 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, 1970 2095 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, 2096 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, 1971 2097 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline, 1972 2098 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)