Changeset 0403187 in Klonkt for src/routes/posts.js


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/routes/posts.js

    r731e431 r0403187  
    116116  if (!Number.isFinite(n) || n < 0) return 0;
    117117  return n;
     118}
     119
     120// Poll durations offered in the editor (seconds) — the Mastodon set (5m … 7d).
     121const POLL_DURATIONS = new Set([300, 1800, 3600, 21600, 43200, 86400, 259200, 604800]);
     122// Parse the editor's poll fields into the poll_json we store on the post (which
     123// buildNote federates as an AS2 Question). Returns null when no valid poll (< 2
     124// options or the poll checkbox is off). endTime is set from the chosen duration
     125// (default 1 day) so the Scheduler can close it.
     126function parsePollForm(body) {
     127  if (!body || !body.poll_enabled) return null;
     128  const raw = body.poll_option == null ? [] : (Array.isArray(body.poll_option) ? body.poll_option : [body.poll_option]);
     129  const options = [];
     130  const seen = new Set();
     131  for (const o of raw) {
     132    const name = String(o == null ? '' : o).trim().slice(0, 100);
     133    if (!name) continue;
     134    const key = name.toLowerCase();
     135    if (seen.has(key)) continue; seen.add(key);
     136    options.push({ name });
     137    if (options.length >= 8) break;
     138  }
     139  if (options.length < 2) return null;
     140  const dur = parseInt(body.poll_duration, 10);
     141  const secs = POLL_DURATIONS.has(dur) ? dur : 86400;
     142  return JSON.stringify({ multiple: !!body.poll_multiple, options, endTime: new Date(Date.now() + secs * 1000).toISOString(), closed: false });
    118143}
    119144
     
    241266  const validTypes = new Set(['post', 'foto', 'video', 'audio']);
    242267  const finalType = validTypes.has(type) ? type : 'post';
     268  const pollJson = parsePollForm(req.body);   // AS2 Question definition, or null
    243269  const postId = uuid();
    244270  const now = new Date().toISOString();
     
    258284    INSERT INTO posts (
    259285      id, site_id, slug, author_id, title, content, excerpt,
    260       status, cover_image_url, cover_video_url, pinned, tags, type, noindex, fan_only, nsfw, content_warning, publish_at,
     286      status, cover_image_url, cover_video_url, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
    261287      created_at, updated_at, published_at
    262     ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     288    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    263289  `).run(
    264290    postId, site.id, finalSlug, req.session.user.id,
     
    266292    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), parsePinnedRank(pinned),
    267293    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    268     finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, publishAt,
     294    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
    269295    now, now, publishedAt
    270296  );
     
    287313        id: postId, slug: finalSlug, title: title || finalSlug,
    288314        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null,
    289         published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw,
     315        published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
    290316      }).catch(() => { /* best-effort */ });
    291317    }
     
    321347  }
    322348
     349  // A poll with votes is frozen (options can't change) — flag it so the editor disables the poll fields.
     350  let pollLocked = false;
     351  try { pollLocked = !!(post.poll_json && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id)); } catch { /* ignore */ }
     352
    323353  renderPage(req, res, 'pages/post-edit', {
    324354    post,
    325355    isNew: false,
     356    pollLocked,
    326357    fediOpenAudio: postAudioFediOpen(site.id, post.content),
    327358    pageTitle: 'Edit: ' + (post.title || 'Untitled'),
     
    353384  const finalType = validTypes.has(type) ? type : (post.type || 'post');
    354385
     386  // A poll that has already received votes is frozen (you can still edit the surrounding
     387  // post, but not the options) — changing options after votes would scramble the tally and
     388  // is disallowed on the fediverse too. Otherwise re-parse the poll form (add/remove/disable).
     389  const hasVotes = !!(post.poll_json && (() => { try { return db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id); } catch { return false; } })());
     390  const pollJson = hasVotes ? post.poll_json : parsePollForm(req.body);
     391
    355392  // Sanitize before storage — same pipeline as create.
    356393  const cleanContent = HtmlSanitizerService.sanitize(content || '');
     
    386423      title = ?, content = ?, excerpt = ?, status = ?,
    387424      cover_image_url = ?, cover_video_url = ?, pinned = ?, tags = ?,
    388       type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, publish_at = ?,
     425      type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
    389426      slug = ?, published_at = ?, updated_at = ?
    390427    WHERE id = ?
     
    393430    cover_image_url || null, (req.body.cover_video_url || null), parsePinnedRank(pinned),
    394431    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    395     finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, publishAt,
     432    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
    396433    finalSlug, publishedAt, now, post.id
    397434  );
     
    418455      id: post.id, slug: finalSlug, title: title || finalSlug,
    419456      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null,
    420       published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw,
     457      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
    421458    };
    422459    if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
     
    10791116  renderPage(req, res, 'pages/post', {
    10801117    post,
     1118    poll: ActivityPubService.ownPollView(post),
    10811119    newerPost,
    10821120    olderPost,
Note: See TracChangeset for help on using the changeset viewer.