Changeset 6053c6c in Klonkt for src/services/ActivityPubService.js
- Timestamp:
- 07/01/2026 07:38:33 AM (2 months ago)
- Branches:
- main
- Children:
- 55a73f0
- Parents:
- f70e010
- File:
-
- 1 edited
-
src/services/ActivityPubService.js (modified) (8 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
rf70e010 r6053c6c 769 769 } 770 770 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. 774 function 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 771 788 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox. 772 789 export async function handleInbox(req, slugParam) { … … 844 861 845 862 // 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')) { 847 864 const o = act.object; 848 865 const tgt = findThreadTarget(o.inReplyTo, base); … … 869 886 } 870 887 const media = JSON.stringify(_atts); 888 const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts 871 889 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce 872 890 // incoming posts to the fediverse — that flooded followers. Boosting to the … … 875 893 for (const s of subs) { 876 894 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 */ } } 877 896 } 878 897 console.log('[AP] timeline +', actorUri, 'x' + subs.length); … … 885 904 // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't 886 905 // 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')) { 888 907 const o = act.object; 889 908 if (o.id && claimedActor) { … … 897 916 .run(html, media, o.sensitive ? 1 : 0, o.summary || null, o.url || null, o.id, claimedActor); 898 917 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 } 899 929 } catch { /* ignore */ } 900 930 // If this note is a cached fediverse reply on one of our posts, refresh its text too. … … 1845 1875 1846 1876 // 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. 1881 export 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 1847 1913 export function isBlockedAny(actorUri) { 1848 1914 if (!actorUri) return false; … … 1899 1965 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote, 1900 1966 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, 1901 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, 1967 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, 1902 1968 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline, 1903 1969 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)