Changeset 0403187 in Klonkt for src/views/pages/post.ejs


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/views/pages/post.ejs

    r731e431 r0403187  
    4444    <%- post.content_html %>
    4545  </div>
     46
     47  <% if (typeof poll !== 'undefined' && poll) { %>
     48    <section class="poll" aria-label="<%= t('poll.aria') %>">
     49      <% poll.options.forEach(function(o){ var _lead = poll.total > 0 && o.count === Math.max.apply(null, poll.options.map(function(x){return x.count;})); %>
     50        <div class="poll-opt<%= _lead ? ' is-lead' : '' %>">
     51          <div class="poll-bar" style="width:<%= o.pct %>%"></div>
     52          <span class="poll-name"><%= o.name %></span>
     53          <span class="poll-pct"><%= o.pct %>%</span>
     54        </div>
     55      <% }); %>
     56      <div class="poll-meta">
     57        <span><%= poll.voters %> <%= poll.voters === 1 ? t('poll.voter_one') : t('poll.voter_many') %></span>
     58        <span aria-hidden="true">·</span>
     59        <% if (poll.closed) { %>
     60          <span><%= t('poll.closed') %></span>
     61        <% } else if (poll.endTime) { %>
     62          <span><%= t('poll.closes') %> <%= new Date(poll.endTime).toLocaleString() %></span>
     63        <% } %>
     64        <% if (poll.multiple) { %><span aria-hidden="true">·</span><span><%= t('poll.multiple') %></span><% } %>
     65      </div>
     66      <p class="poll-note"><%= t('poll.fedi_only') %></p>
     67    </section>
     68  <% } %>
    4669
    4770  <% if (post.tags && post.tags.length > 0) { %>
     
    210233.post-content { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); }
    211234.post-content p { margin: 1.2em 0; }
     235/* Poll (a hosted AS2 Question) — display-only; voting happens from the fediverse. */
     236.poll { margin: 1.75rem 0; display: flex; flex-direction: column; gap: .5rem; }
     237.poll-opt { position: relative; display: flex; align-items: center; gap: .5rem; padding: .55rem .75rem; border: 1px solid var(--line, rgba(128,128,128,.25)); border-radius: 8px; overflow: hidden; font-size: .98rem; }
     238.poll-bar { position: absolute; inset: 0 auto 0 0; background: color-mix(in srgb, var(--accent) 18%, transparent); z-index: 0; transition: width .3s ease; }
     239.poll-opt.is-lead .poll-bar { background: color-mix(in srgb, var(--accent) 30%, transparent); }
     240.poll-name { position: relative; z-index: 1; flex: 1; color: var(--ink); }
     241.poll-opt.is-lead .poll-name { font-weight: 600; }
     242.poll-pct { position: relative; z-index: 1; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
     243.poll-meta { display: flex; flex-wrap: wrap; gap: .4rem; font-size: .85rem; color: var(--ink-muted); }
     244.poll-note { font-size: .8rem; color: var(--ink-muted); margin: .1rem 0 0; }
    212245.like-btn {
    213246  display: inline-flex; align-items: center; gap: 0.45rem;
Note: See TracChangeset for help on using the changeset viewer.