Changeset 6053c6c in Klonkt


Ignore:
Timestamp:
07/01/2026 07:38:33 AM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
55a73f0
Parents:
f70e010
Message:

feat(fediverse): show + vote on remote polls (Question) in the News feed

Inbound ActivityPub polls, phase 1. A Question (the Mastodon-standard poll)
from a followed account is now cached and rendered in the News feed with its
options, vote counts and close state, and the owner can vote — a ballot is a
Create(Note) carrying only the chosen option's name + inReplyTo the Question,
addressed to the poll's author (the fediverse-standard vote). The author's
Update(Question) refreshes the authoritative counts; our own vote is preserved.

  • config/database.js — ap_timeline.poll_json column
  • services/ActivityPubService.js — parsePoll(); handle Create/Update(Question); voteOnPoll() sends the ballot + optimistic local tally
  • routes/posts.js — parse poll_json for the feed; POST /news/vote (owner-only)
  • views/pages/news.ejs — poll UI (votable options / result bars) + styles
  • services/i18n.js — poll.vote/votes/closed (nl/en/de)

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

Location:
src
Files:
5 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    rf70e010 r6053c6c  
    342342  ensureColumn('ap_timeline', 'reblog_handle', 'TEXT');      //   the booster's @handle
    343343  ensureColumn('ap_timeline', 'reblog_icon', 'TEXT');        //   the booster's avatar
     344  ensureColumn('ap_timeline', 'poll_json', 'TEXT');          // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
    344345}
    345346
  • src/routes/posts.js

    rf70e010 r6053c6c  
    713713    // top-level "open the player" link that works even when a browser shield/CSP blocks
    714714    // the cross-site iframe (a full-page navigation is not a cross-site frame).
    715     return { ...p, content, embedHtml, embedUrl };
     715    let poll = null;
     716    if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
     717    return { ...p, content, embedHtml, embedUrl, poll };
    716718  });
    717719  // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
     
    799801  }
    800802  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     803  res.redirect('/news');
     804});
     805
     806// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
     807router.post('/news/vote', requireSiteManager, async (req, res) => {
     808  const site = res.locals.site;
     809  const note = (req.body.note || '').toString();
     810  let choice = req.body.choice;
     811  if (choice == null) choice = [];
     812  if (!Array.isArray(choice)) choice = [choice];
     813  if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
    801814  res.redirect('/news');
    802815});
  • src/services/ActivityPubService.js

    rf70e010 r6053c6c  
    769769}
    770770
     771// Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
     772// into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
     773// with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
     774function parsePoll(o) {
     775  if (!o || o.type !== 'Question') return null;
     776  const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
     777  if (!raw || !raw.length) return null;
     778  const options = raw.slice(0, 12).map((opt) => ({
     779    name: String((opt && opt.name) || '').slice(0, 300),
     780    count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
     781  })).filter((x) => x.name);
     782  if (!options.length) return null;
     783  const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
     784  const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
     785  return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
     786}
     787
    771788// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    772789export async function handleInbox(req, slugParam) {
     
    844861
    845862  // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
    846   if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
     863  if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
    847864    const o = act.object;
    848865    const tgt = findThreadTarget(o.inReplyTo, base);
     
    869886        }
    870887        const media = JSON.stringify(_atts);
     888        const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
    871889        // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
    872890        // incoming posts to the fediverse — that flooded followers. Boosting to the
     
    875893        for (const s of subs) {
    876894          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
     895          if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
    877896        }
    878897        console.log('[AP] timeline +', actorUri, 'x' + subs.length);
     
    885904  // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
    886905  // edit A's note (the signature gate guarantees claimedActor == the verified signer).
    887   if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
     906  if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
    888907    const o = act.object;
    889908    if (o.id && claimedActor) {
     
    897916          .run(html, media, o.sensitive ? 1 : 0, o.summary || null, o.url || null, o.id, claimedActor);
    898917        if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
     918        // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
     919        // site keeps its own `voted` state while the counts/closed update to the new totals.
     920        const poll = parsePoll(o);
     921        if (poll) {
     922          const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
     923          const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
     924          for (const rw of rows) {
     925            let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
     926            upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
     927          }
     928        }
    899929      } catch { /* ignore */ }
    900930      // If this note is a cached fediverse reply on one of our posts, refresh its text too.
     
    18451875
    18461876// True if an actor (or its whole domain) is blocked anywhere on this instance.
     1877// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
     1878// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
     1879// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
     1880// author's Update(Question) refreshes the authoritative totals when it arrives.
     1881export async function voteOnPoll(site, questionId, choices) {
     1882  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     1883  if (!base || !site || !site.slug || !questionId) return { error: 'config' };
     1884  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 */ }
     1885  if (!row || !row.poll_json) return { error: 'not_found' };
     1886  let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
     1887  if (poll.closed) return { error: 'closed' };
     1888  if (poll.voted) return { error: 'already' };
     1889  const valid = new Set(poll.options.map((o) => o.name));
     1890  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
     1891  if (!picks.length) return { error: 'invalid' };
     1892  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
     1893  const me = actorId(base, site.slug);
     1894  const keys = getOrCreateKeys(site.slug);
     1895  const authorUri = row.author_uri || null;
     1896  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
     1897  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
     1898  if (!inbox) return { error: 'unreachable' };
     1899  for (const name of chosen) {
     1900    const nid = `${me}/votes/${Date.now()}-${rid()}`;
     1901    const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
     1902    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
     1903    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
     1904  }
     1905  // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
     1906  poll.voted = poll.multiple ? chosen : chosen[0];
     1907  for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
     1908  if (poll.voters != null) poll.voters += 1;
     1909  try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
     1910  return { ok: true };
     1911}
     1912
    18471913export function isBlockedAny(actorUri) {
    18481914  if (!actorUri) return false;
     
    18991965  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    19001966  listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
    1901   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction,
     1967  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll,
    19021968  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    19031969  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
  • src/services/i18n.js

    rf70e010 r6053c6c  
    113113    'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse',
    114114    'fedi.reply': 'Reageer', 'fedi.reply_ph': 'Je antwoord aan de fediverse…', 'fedi.send': 'Versturen', 'fedi.you': 'Jij',
    115     'fedi.remote_title': 'Reageer via de fediverse', 'fedi.follow_heading': 'Volgen via de fediverse', 'fedi.profile_follow': 'Volg via de fediverse', 'profile.since': 'Op Klonkt sinds', 'profile.free': 'Gratis', 'fedi.follow_intro': 'Je staat op het punt te volgen:', 'fedi.follow_btn': 'Volgen', 'fedi.cancel': 'Annuleren', 'fedi.followed_title': 'Volgverzoek verstuurd ✅', 'fedi.followed_done': 'Je volgverzoek is onderweg. Zodra de andere kant het accepteert, verschijnen hun berichten in je tijdlijn.', 'fedi.view_profile': 'Bekijk profiel →', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres:', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Like intrekken', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'jouw server', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is verstuurd. Hij verschijnt zo bij de originele post op de fediverse, niet op deze pagina. Bekijk hem daar:', 'fedi.reply_where': 'Je reactie verschijnt bij de originele post op de fediverse, niet op deze pagina. Via de link hierboven zie je hem daar.', 'fedi.remote_back': '← Terug naar je site', 'fedi.like_btn': 'Like deze post', 'fedi.or_reply': 'of reageer:', 'fedi.liked_title': 'Geliket', 'fedi.liked_done': 'Je like is onderweg naar de fediverse.', 'fedi.boost_btn': 'Boost deze post', 'fedi.boosted_title': 'Geboost', 'fedi.boosted_done': 'Je boost is onderweg naar de fediverse.', 'fedi.remote_interact': 'Interageer via de fediverse', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'fedi.goto_post': 'Naar de post', 'fedi.edit': 'Bewerken', 'fedi.save_edit': 'Opslaan', 'fedi.bm_label': 'Interageer via mijn site', 'fedi.bm_help': 'Sleep deze knop naar je bladwijzerbalk. Klik ’m daarna op elke fediverse-post (Mastodon, een andere Klonkt…) om er via jouw site op te reageren, te liken of te boosten.', 'tl.title': 'Krant', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.autoboost': 'Uitgelicht', 'tl.autoboost_follow': 'uitlichten in cirkel', 'tl.autoboost_hint': 'Hun nieuwe posts verschijnen doorlopend in jouw Cirkel (lokaal, geen fediverse-boost).', 'tl.pending': 'in afwachting', 'tl.unboost': 'Boost intrekken', 'tl.feed': 'Berichten', 'tl.tab_feed': 'Krant', 'tl.tab_following': 'Volgend', 'tl.tab_replies': 'Reacties', 'tl.empty_following': 'Je volgt nog niemand.', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 'tl.open_player': 'Open de speler', 'tl.paste_ph': 'Plak een fediverse-post-URL', 'tl.paste_go': 'Openen', 'tl.boosted': 'boostte dit', 'tl.read_more': 'Meer lezen', 'tl.show_less': 'Minder',
     115    'fedi.remote_title': 'Reageer via de fediverse', 'fedi.follow_heading': 'Volgen via de fediverse', 'fedi.profile_follow': 'Volg via de fediverse', 'profile.since': 'Op Klonkt sinds', 'profile.free': 'Gratis', 'fedi.follow_intro': 'Je staat op het punt te volgen:', 'fedi.follow_btn': 'Volgen', 'fedi.cancel': 'Annuleren', 'fedi.followed_title': 'Volgverzoek verstuurd ✅', 'fedi.followed_done': 'Je volgverzoek is onderweg. Zodra de andere kant het accepteert, verschijnen hun berichten in je tijdlijn.', 'fedi.view_profile': 'Bekijk profiel →', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres:', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Like intrekken', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'jouw server', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is verstuurd. Hij verschijnt zo bij de originele post op de fediverse, niet op deze pagina. Bekijk hem daar:', 'fedi.reply_where': 'Je reactie verschijnt bij de originele post op de fediverse, niet op deze pagina. Via de link hierboven zie je hem daar.', 'fedi.remote_back': '← Terug naar je site', 'fedi.like_btn': 'Like deze post', 'fedi.or_reply': 'of reageer:', 'fedi.liked_title': 'Geliket', 'fedi.liked_done': 'Je like is onderweg naar de fediverse.', 'fedi.boost_btn': 'Boost deze post', 'fedi.boosted_title': 'Geboost', 'fedi.boosted_done': 'Je boost is onderweg naar de fediverse.', 'fedi.remote_interact': 'Interageer via de fediverse', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'fedi.goto_post': 'Naar de post', 'fedi.edit': 'Bewerken', 'fedi.save_edit': 'Opslaan', 'fedi.bm_label': 'Interageer via mijn site', 'fedi.bm_help': 'Sleep deze knop naar je bladwijzerbalk. Klik ’m daarna op elke fediverse-post (Mastodon, een andere Klonkt…) om er via jouw site op te reageren, te liken of te boosten.', 'tl.title': 'Krant', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.autoboost': 'Uitgelicht', 'tl.autoboost_follow': 'uitlichten in cirkel', 'tl.autoboost_hint': 'Hun nieuwe posts verschijnen doorlopend in jouw Cirkel (lokaal, geen fediverse-boost).', 'tl.pending': 'in afwachting', 'tl.unboost': 'Boost intrekken', 'tl.feed': 'Berichten', 'tl.tab_feed': 'Krant', 'tl.tab_following': 'Volgend', 'tl.tab_replies': 'Reacties', 'tl.empty_following': 'Je volgt nog niemand.', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 'tl.open_player': 'Open de speler', 'tl.paste_ph': 'Plak een fediverse-post-URL', 'tl.paste_go': 'Openen', 'tl.boosted': 'boostte dit', 'tl.read_more': 'Meer lezen', 'tl.show_less': 'Minder', 'poll.vote': 'Stem', 'poll.votes': 'stemmen', 'poll.closed': 'gesloten',
    116116    'comments.to_start': 'om de conversatie te starten.',
    117117    'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren',
     
    10331033    'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse',
    10341034    'fedi.reply': 'Reply', 'fedi.reply_ph': 'Your reply to the fediverse…', 'fedi.send': 'Send', 'fedi.you': 'You',
    1035     'fedi.remote_title': 'Reply via the fediverse', 'fedi.follow_heading': 'Follow via the fediverse', 'fedi.profile_follow': 'Follow via the fediverse', 'profile.since': 'On Klonkt since', 'profile.free': 'Free', 'fedi.follow_intro': 'You are about to follow:', 'fedi.follow_btn': 'Follow', 'fedi.cancel': 'Cancel', 'fedi.followed_title': 'Follow request sent ✅', 'fedi.followed_done': 'Your follow request is on its way. Once accepted, their posts show up in your timeline.', 'fedi.view_profile': 'View profile →', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address:', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Unlike', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'your server', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply has been sent. It will show up on the original post on the fediverse, not on this page. See it there:', 'fedi.reply_where': 'Your reply appears on the original post on the fediverse, not on this page. Use the link above to see it there.', 'fedi.remote_back': '← Back to your site', 'fedi.like_btn': 'Like this post', 'fedi.or_reply': 'or reply:', 'fedi.liked_title': 'Liked', 'fedi.liked_done': 'Your like is on its way to the fediverse.', 'fedi.boost_btn': 'Boost this post', 'fedi.boosted_title': 'Boosted', 'fedi.boosted_done': 'Your boost is on its way to the fediverse.', 'fedi.remote_interact': 'Interact via the fediverse', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'fedi.goto_post': 'Go to post', 'fedi.edit': 'Edit', 'fedi.save_edit': 'Save', 'fedi.bm_label': 'Interact via my site', 'fedi.bm_help': 'Drag this button to your bookmarks bar. Then click it on any fediverse post (Mastodon, another Klonkt…) to reply, like or boost it via your own site.', 'tl.title': 'News', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.autoboost': 'Featured', 'tl.autoboost_follow': 'feature in circle', 'tl.autoboost_hint': 'Their new posts keep showing in your Circle (local, no fediverse boost).', 'tl.pending': 'pending', 'tl.unboost': 'Unboost', 'tl.feed': 'Posts', 'tl.tab_feed': 'News', 'tl.tab_following': 'Following', 'tl.tab_replies': 'Replies', 'tl.empty_following': 'You do not follow anyone yet.', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 'tl.open_player': 'Open the player', 'tl.paste_ph': 'Paste a fediverse post URL', 'tl.paste_go': 'Open', 'tl.boosted': 'boosted', 'tl.read_more': 'Read more', 'tl.show_less': 'Show less',
     1035    'fedi.remote_title': 'Reply via the fediverse', 'fedi.follow_heading': 'Follow via the fediverse', 'fedi.profile_follow': 'Follow via the fediverse', 'profile.since': 'On Klonkt since', 'profile.free': 'Free', 'fedi.follow_intro': 'You are about to follow:', 'fedi.follow_btn': 'Follow', 'fedi.cancel': 'Cancel', 'fedi.followed_title': 'Follow request sent ✅', 'fedi.followed_done': 'Your follow request is on its way. Once accepted, their posts show up in your timeline.', 'fedi.view_profile': 'View profile →', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address:', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Unlike', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'your server', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply has been sent. It will show up on the original post on the fediverse, not on this page. See it there:', 'fedi.reply_where': 'Your reply appears on the original post on the fediverse, not on this page. Use the link above to see it there.', 'fedi.remote_back': '← Back to your site', 'fedi.like_btn': 'Like this post', 'fedi.or_reply': 'or reply:', 'fedi.liked_title': 'Liked', 'fedi.liked_done': 'Your like is on its way to the fediverse.', 'fedi.boost_btn': 'Boost this post', 'fedi.boosted_title': 'Boosted', 'fedi.boosted_done': 'Your boost is on its way to the fediverse.', 'fedi.remote_interact': 'Interact via the fediverse', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'fedi.goto_post': 'Go to post', 'fedi.edit': 'Edit', 'fedi.save_edit': 'Save', 'fedi.bm_label': 'Interact via my site', 'fedi.bm_help': 'Drag this button to your bookmarks bar. Then click it on any fediverse post (Mastodon, another Klonkt…) to reply, like or boost it via your own site.', 'tl.title': 'News', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.autoboost': 'Featured', 'tl.autoboost_follow': 'feature in circle', 'tl.autoboost_hint': 'Their new posts keep showing in your Circle (local, no fediverse boost).', 'tl.pending': 'pending', 'tl.unboost': 'Unboost', 'tl.feed': 'Posts', 'tl.tab_feed': 'News', 'tl.tab_following': 'Following', 'tl.tab_replies': 'Replies', 'tl.empty_following': 'You do not follow anyone yet.', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 'tl.open_player': 'Open the player', 'tl.paste_ph': 'Paste a fediverse post URL', 'tl.paste_go': 'Open', 'tl.boosted': 'boosted', 'tl.read_more': 'Read more', 'tl.show_less': 'Show less', 'poll.vote': 'Vote', 'poll.votes': 'votes', 'poll.closed': 'closed',
    10361036    'comments.to_start': 'to start the conversation.',
    10371037    'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel',
     
    19511951    'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse',
    19521952    'fedi.reply': 'Antworten', 'fedi.reply_ph': 'Deine Antwort an das Fediverse…', 'fedi.send': 'Senden', 'fedi.you': 'Du',
    1953     'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.follow_heading': 'Über das Fediverse folgen', 'fedi.profile_follow': 'Über das Fediverse folgen', 'profile.since': 'Auf Klonkt seit', 'profile.free': 'Kostenlos', 'fedi.follow_intro': 'Du folgst gleich:', 'fedi.follow_btn': 'Folgen', 'fedi.cancel': 'Abbrechen', 'fedi.followed_title': 'Folge-Anfrage gesendet ✅', 'fedi.followed_done': 'Deine Folge-Anfrage ist unterwegs. Sobald sie akzeptiert wird, erscheinen ihre Beiträge in deiner Timeline.', 'fedi.view_profile': 'Profil ansehen →', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse:', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.like_short': 'Liken', 'fedi.unlike_short': 'Like zurücknehmen', 'fedi.boost_short': 'Boosten', 'fedi.remote_ph': 'dein Server', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort wurde gesendet. Sie erscheint gleich beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Sieh sie dir dort an:', 'fedi.reply_where': 'Deine Antwort erscheint beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Über den Link oben siehst du sie dort.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.like_btn': 'Diesen Beitrag liken', 'fedi.or_reply': 'oder antworten:', 'fedi.liked_title': 'Geliked', 'fedi.liked_done': 'Dein Like ist unterwegs ins Fediverse.', 'fedi.boost_btn': 'Diesen Beitrag boosten', 'fedi.boosted_title': 'Geboostet', 'fedi.boosted_done': 'Dein Boost ist unterwegs ins Fediverse.', 'fedi.remote_interact': 'Übers Fediverse interagieren', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'fedi.goto_post': 'Zur Post', 'fedi.edit': 'Bearbeiten', 'fedi.save_edit': 'Speichern', 'fedi.bm_label': 'Über meine Seite interagieren', 'fedi.bm_help': 'Zieh diesen Button in deine Lesezeichenleiste. Klick ihn dann auf einem beliebigen Fediverse-Beitrag (Mastodon, ein anderes Klonkt…), um über deine eigene Seite zu antworten, zu liken oder zu boosten.', 'tl.title': 'Zeitung', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.autoboost': 'Hervorgehoben', 'tl.autoboost_follow': 'im Zirkel hervorheben', 'tl.autoboost_hint': 'Ihre neuen Beiträge erscheinen laufend in deinem Zirkel (lokal, kein Fediverse-Boost).', 'tl.pending': 'ausstehend', 'tl.unboost': 'Boost zurücknehmen', 'tl.feed': 'Beiträge', 'tl.tab_feed': 'Zeitung', 'tl.tab_following': 'Folge ich', 'tl.tab_replies': 'Antworten', 'tl.empty_following': 'Du folgst noch niemandem.', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 'tl.open_player': 'Player öffnen', 'tl.paste_ph': 'URL eines Fediverse-Beitrags einfügen', 'tl.paste_go': 'Öffnen', 'tl.boosted': 'hat geteilt', 'tl.read_more': 'Mehr lesen', 'tl.show_less': 'Weniger',
     1953    'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.follow_heading': 'Über das Fediverse folgen', 'fedi.profile_follow': 'Über das Fediverse folgen', 'profile.since': 'Auf Klonkt seit', 'profile.free': 'Kostenlos', 'fedi.follow_intro': 'Du folgst gleich:', 'fedi.follow_btn': 'Folgen', 'fedi.cancel': 'Abbrechen', 'fedi.followed_title': 'Folge-Anfrage gesendet ✅', 'fedi.followed_done': 'Deine Folge-Anfrage ist unterwegs. Sobald sie akzeptiert wird, erscheinen ihre Beiträge in deiner Timeline.', 'fedi.view_profile': 'Profil ansehen →', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse:', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.like_short': 'Liken', 'fedi.unlike_short': 'Like zurücknehmen', 'fedi.boost_short': 'Boosten', 'fedi.remote_ph': 'dein Server', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort wurde gesendet. Sie erscheint gleich beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Sieh sie dir dort an:', 'fedi.reply_where': 'Deine Antwort erscheint beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Über den Link oben siehst du sie dort.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.like_btn': 'Diesen Beitrag liken', 'fedi.or_reply': 'oder antworten:', 'fedi.liked_title': 'Geliked', 'fedi.liked_done': 'Dein Like ist unterwegs ins Fediverse.', 'fedi.boost_btn': 'Diesen Beitrag boosten', 'fedi.boosted_title': 'Geboostet', 'fedi.boosted_done': 'Dein Boost ist unterwegs ins Fediverse.', 'fedi.remote_interact': 'Übers Fediverse interagieren', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'fedi.goto_post': 'Zur Post', 'fedi.edit': 'Bearbeiten', 'fedi.save_edit': 'Speichern', 'fedi.bm_label': 'Über meine Seite interagieren', 'fedi.bm_help': 'Zieh diesen Button in deine Lesezeichenleiste. Klick ihn dann auf einem beliebigen Fediverse-Beitrag (Mastodon, ein anderes Klonkt…), um über deine eigene Seite zu antworten, zu liken oder zu boosten.', 'tl.title': 'Zeitung', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.autoboost': 'Hervorgehoben', 'tl.autoboost_follow': 'im Zirkel hervorheben', 'tl.autoboost_hint': 'Ihre neuen Beiträge erscheinen laufend in deinem Zirkel (lokal, kein Fediverse-Boost).', 'tl.pending': 'ausstehend', 'tl.unboost': 'Boost zurücknehmen', 'tl.feed': 'Beiträge', 'tl.tab_feed': 'Zeitung', 'tl.tab_following': 'Folge ich', 'tl.tab_replies': 'Antworten', 'tl.empty_following': 'Du folgst noch niemandem.', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 'tl.open_player': 'Player öffnen', 'tl.paste_ph': 'URL eines Fediverse-Beitrags einfügen', 'tl.paste_go': 'Öffnen', 'tl.boosted': 'hat geteilt', 'tl.read_more': 'Mehr lesen', 'tl.show_less': 'Weniger', 'poll.vote': 'Abstimmen', 'poll.votes': 'Stimmen', 'poll.closed': 'geschlossen',
    19541954    'comments.to_start': 'um das Gespräch zu starten.',
    19551955    'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen',
  • src/views/pages/news.ejs

    rf70e010 r6053c6c  
    132132          <% if (p.embedUrl) { %><a class="tl-embed-open" href="<%= p.embedUrl %>" target="_blank" rel="noopener">▶ <%= t('tl.open_player') %></a><% } %>
    133133
     134          <% if (p.poll) { var _pl=p.poll; var _tot=_pl.options.reduce(function(s,o){return s+(o.count||0);},0); var _voteable=!_pl.closed&&!_pl.voted; %>
     135          <div class="tl-poll">
     136            <% if (_voteable) { %>
     137              <form method="post" action="/news/vote" class="tl-poll-form">
     138                <input type="hidden" name="note" value="<%= p.id %>">
     139                <% _pl.options.forEach(function(o){ %><label class="tl-poll-choice"><input type="<%= _pl.multiple?'checkbox':'radio' %>" name="choice" value="<%= o.name %>"><span><%= o.name %></span></label><% }); %>
     140                <button type="submit" class="btn btn-primary tl-poll-btn"><%= t('poll.vote') %></button>
     141              </form>
     142            <% } else { _pl.options.forEach(function(o){ var _pct=_tot?Math.round((o.count||0)*100/_tot):0; var _mine=_pl.voted&&(Array.isArray(_pl.voted)?_pl.voted.indexOf(o.name)>=0:_pl.voted===o.name); %><div class="tl-poll-res<%= _mine?' is-mine':'' %>"><span class="tl-poll-fill" style="width:<%= _pct %>%"></span><span class="tl-poll-name"><%= _mine?'✓ ':'' %><%= o.name %></span><span class="tl-poll-pct"><%= _pct %>%</span></div><% }); } %>
     143            <div class="tl-poll-foot"><%= (_pl.voters!=null?_pl.voters:_tot) %> <%= t('poll.votes') %><% if(_pl.closed){ %> · <%= t('poll.closed') %><% } %></div>
     144          </div>
     145          <% } %>
     146
    134147          <% if (p.url) { %><a class="tl-orig" href="<%= p.url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
    135148
     
    199212  .tl-media-video, .tl-media-audio { width: 100%; margin: .75rem 0 0; border-radius: 12px; display: block; }
    200213  .tl-media-video { max-height: 480px; background: #000; }
     214  .tl-poll { margin: .75rem 0 0; display: flex; flex-direction: column; gap: 8px; }
     215  .tl-poll-form { display: flex; flex-direction: column; gap: 8px; }
     216  .tl-poll-choice { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line, rgba(128,128,128,.3)); border-radius: 10px; cursor: pointer; }
     217  .tl-poll-choice:hover { border-color: var(--accent); }
     218  .tl-poll-choice input { accent-color: var(--accent); }
     219  .tl-poll-btn { align-self: flex-start; margin-top: 2px; }
     220  .tl-poll-res { position: relative; padding: 9px 12px; border-radius: 10px; overflow: hidden; background: var(--paper-2, rgba(128,128,128,.08)); display: flex; align-items: center; gap: 8px; }
     221  .tl-poll-fill { position: absolute; inset: 0 auto 0 0; background: color-mix(in srgb, var(--accent) 22%, transparent); z-index: 0; }
     222  .tl-poll-res.is-mine .tl-poll-fill { background: color-mix(in srgb, var(--accent) 40%, transparent); }
     223  .tl-poll-name { position: relative; z-index: 1; flex: 1; font-size: .95rem; }
     224  .tl-poll-pct { position: relative; z-index: 1; font-variant-numeric: tabular-nums; font-weight: 500; }
     225  .tl-poll-foot { font-size: .82rem; color: var(--ink-soft, #888); }
    201226
    202227  .tl-embed { margin: .75rem 0 0; border-radius: 12px; overflow: hidden; background: var(--paper-2, #111); }
Note: See TracChangeset for help on using the changeset viewer.