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

feat(polls): vote on any fediverse poll from the interact page

The interact page (paste any post URL) now detects a Question and shows a ballot
(radio/checkbox + Vote) so you can vote on any fediverse poll by URL — not only
polls from accounts you follow (which vote via /news). Casts the Mastodon-standard
ballot straight to the poll's author, no timeline cache needed.

  • src/services/ActivityPubService.js — voteOnRemotePoll(site, url, choices) fetches the Question fresh, validates the choice(s) and delivers the ballot to the author; resolveRemoteNote now returns the parsed poll so the view can render options.
  • src/routes/posts.js — POST /authorize_interaction/vote; GET passes voted and skips re-resolving the target on the confirmation view.
  • src/views/pages/authorize-interaction.ejs — ballot (open) / results (closed) above the like/boost/reply actions, a "vote sent" confirmation, and scoped .auth-poll styles.
  • src/services/i18n.js — poll.voted_title/_done (nl/en/de).

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rbb3f67c r667fb41  
    15431543    threadInboxes,                                          // every ancestor author's inbox
    15441544    localPostId: localTgt ? localTgt.post_id : '',          // our post this belongs to (if any)
     1545    poll: parsePoll(note),                                  // a Question → its options/counts (else null)
    15451546    preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
    15461547  };
     
    20392040}
    20402041
     2042// Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
     2043// the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
     2044// Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
     2045// by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
     2046export async function voteOnRemotePoll(site, questionUrl, choices) {
     2047  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     2048  if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
     2049  const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
     2050  if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
     2051  const poll = parsePoll(q);
     2052  if (!poll) return { error: 'not_found' };
     2053  if (poll.closed) return { error: 'closed' };
     2054  const valid = new Set(poll.options.map((o) => o.name));
     2055  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
     2056  if (!picks.length) return { error: 'invalid' };
     2057  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
     2058  const authorUri = actorUriOf(q.attributedTo);
     2059  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
     2060  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
     2061  if (!inbox) return { error: 'unreachable' };
     2062  const me = actorId(base, site.slug);
     2063  const keys = getOrCreateKeys(site.slug);
     2064  for (const name of chosen) {
     2065    const nid = `${me}/votes/${Date.now()}-${rid()}`;
     2066    const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
     2067    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
     2068    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
     2069  }
     2070  return { ok: true };
     2071}
     2072
    20412073export function isBlockedAny(actorUri) {
    20422074  if (!actorUri) return false;
     
    20932125  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    20942126  listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
    2095   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll,
     2127  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, voteOnRemotePoll,
    20962128  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
    20972129  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
Note: See TracChangeset for help on using the changeset viewer.