Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Polls & Poll Answers #3248

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions lexicons/app/bsky/embed/poll.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"lexicon": 1,
"id": "app.bsky.embed.poll",
"defs": {
"main": {
"type": "object",
"required": ["question", "options"],
"properties": {
"question": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300,
"description": "The question being asked."
},
"options": {
"type": "array",
"items": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300,
"description": "The options available for the poll."
},
"minLength": 1,
"maxLength": 4
}
Copy link

@Tamschi Tamschi Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only covers single-choice polls in terms of poll definition.

Rather than a binary single-choice and any-number-of-choices system, it would be cool to have the maximum number of distinct selections given as an integer:

Suggested change
}
},
"maxAnswers": {
"type": "integer",
"minimum": 1,
"default": 1
}

It should be specified what happens when the number is changed, but that's necessary anyway because multiple-choice polls (if/once allowed) can be edited into single-choice ones and vice versa. (The easiest solution would be to discard/disregard all previous answers when poll post content is edited of course, which is what Mastodon does I believe.)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good to have too. I'm going to hold off on committing this in for now, as I would like to figure out the validation behavior that will allow this to work properly. Your mention of poll editing is key here. In the last question that I answered I was considering moving to having Polls be their own record, and, that may be necessary to handle poll editing properly, ensuring that PollAnswers are discarded, by nature of referencing a stale copy of the poll at edit time (that is to say, editing a poll would create a new poll record).

}
},
"view": {
"type": "object",
"required": ["question", "options"],
"properties": {
"question": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300,
"description": "The question being asked."
},
"options": {
"type": "array",
"items": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300,
"description": "The options available for the poll."
},
"minLength": 1,
"maxLength": 4
}
}
}
}
}
8 changes: 7 additions & 1 deletion lexicons/app/bsky/feed/defs.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@
"app.bsky.embed.video#view",
"app.bsky.embed.external#view",
"app.bsky.embed.record#view",
"app.bsky.embed.recordWithMedia#view"
"app.bsky.embed.recordWithMedia#view",
"app.bsky.embed.poll#view"
]
},
"replyCount": { "type": "integer" },
"repostCount": { "type": "integer" },
"likeCount": { "type": "integer" },
"quoteCount": { "type": "integer" },
"pollAnswerCount": { "type": "integer" },
"pollAnswers": {
"type": "array",
"items": { "type": "integer" }
},
"indexedAt": { "type": "string", "format": "datetime" },
"viewer": { "type": "ref", "ref": "#viewerState" },
"labels": {
Expand Down
83 changes: 83 additions & 0 deletions lexicons/app/bsky/feed/getPollAnswers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{
"lexicon": 1,
"id": "app.bsky.feed.getPollAnswers",
"defs": {
"main": {
"type": "query",
"description": "Get poll answers for a given poll which reference a post.",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"description": "Get poll answers for a given poll which reference a post.",
"description": "Get poll answers which reference a given post.",

I think this is a bit confusing in terms of protocol design, since for example the poll could be edited out of a post and nothing is stopping a user from submitting a app.bsky.feed.pollAnswer that references a post that doesn't exist (yet) or doesn't have a poll (yet). The latter case could easily happen during normal use if multiple AppViews are involved.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a downside to the implementation I have put together so far. I considered instead creating an individual record called a Poll, which would then be associated with a Post, which could mitigate this. The issue I had with that is then you have this object that can only really ever have a relationship to a post, or can otherwise just be orphaned, and I figured that wouldn't be very useful.

I'm open to changing this though!

"parameters": {
"type": "params",
"required": ["uri"],
"properties": {
"uri": {
"type": "string",
"format": "at-uri",
"description": "AT-URI of the subject (eg, a post record)."
},
"cid": {
"type": "string",
"format": "cid",
"description": "CID of the subject record (aka, specific version of record), to filter poll answers."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50
},
"cursor": {
"type": "string"
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["uri"],
"properties": {
"uri": {
"type": "string",
"format": "at-uri"
},
"cid": {
"type": "string",
"format": "cid"
},
"cursor": {
"type": "string"
},
"pollAnswers": {
"type": "array",
"items": {
"type": "ref",
"ref": "#pollAnswer"
}
}
}
}
}
},
"pollAnswer": {
"type": "object",
"required": ["indexedAt", "createdAt", "actor", "answer"],
"properties": {
"indexedAt": {
"type": "string",
"format": "datetime"
},
"createdAt": {
"type": "string",
"format": "datetime"
},
"actor": {
"type": "ref",
"ref": "app.bsky.actor.defs#profileView"
},
"answer": {
"type": "integer"
}
}
}
}
}
25 changes: 25 additions & 0 deletions lexicons/app/bsky/feed/pollAnswer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"lexicon": 1,
"id": "app.bsky.feed.pollAnswer",
"defs": {
"main": {
"type": "record",
"description": "Record declaring a user's answer to a poll.",
"key": "tid",
"record": {
"type": "object",
"required": ["subject", "answer", "createdAt"],
"properties": {
"subject": { "type": "ref", "ref": "com.atproto.repo.strongRef" },
"createdAt": { "type": "string", "format": "datetime" },
"answer": {
"type": "integer",
"minimum": 1,
"maximum": 15,
"description": "The index of the option selected by the user."
}
}
}
}
}
}
85 changes: 85 additions & 0 deletions packages/api/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import * as AppBskyActorSearchActorsTypeahead from './types/app/bsky/actor/searc
import * as AppBskyEmbedDefs from './types/app/bsky/embed/defs'
import * as AppBskyEmbedExternal from './types/app/bsky/embed/external'
import * as AppBskyEmbedImages from './types/app/bsky/embed/images'
import * as AppBskyEmbedPoll from './types/app/bsky/embed/poll'
import * as AppBskyEmbedRecord from './types/app/bsky/embed/record'
import * as AppBskyEmbedRecordWithMedia from './types/app/bsky/embed/recordWithMedia'
import * as AppBskyEmbedVideo from './types/app/bsky/embed/video'
Expand All @@ -112,13 +113,15 @@ import * as AppBskyFeedGetFeedGenerators from './types/app/bsky/feed/getFeedGene
import * as AppBskyFeedGetFeedSkeleton from './types/app/bsky/feed/getFeedSkeleton'
import * as AppBskyFeedGetLikes from './types/app/bsky/feed/getLikes'
import * as AppBskyFeedGetListFeed from './types/app/bsky/feed/getListFeed'
import * as AppBskyFeedGetPollAnswers from './types/app/bsky/feed/getPollAnswers'
import * as AppBskyFeedGetPostThread from './types/app/bsky/feed/getPostThread'
import * as AppBskyFeedGetPosts from './types/app/bsky/feed/getPosts'
import * as AppBskyFeedGetQuotes from './types/app/bsky/feed/getQuotes'
import * as AppBskyFeedGetRepostedBy from './types/app/bsky/feed/getRepostedBy'
import * as AppBskyFeedGetSuggestedFeeds from './types/app/bsky/feed/getSuggestedFeeds'
import * as AppBskyFeedGetTimeline from './types/app/bsky/feed/getTimeline'
import * as AppBskyFeedLike from './types/app/bsky/feed/like'
import * as AppBskyFeedPollAnswer from './types/app/bsky/feed/pollAnswer'
import * as AppBskyFeedPost from './types/app/bsky/feed/post'
import * as AppBskyFeedPostgate from './types/app/bsky/feed/postgate'
import * as AppBskyFeedRepost from './types/app/bsky/feed/repost'
Expand Down Expand Up @@ -324,6 +327,7 @@ export * as AppBskyActorSearchActorsTypeahead from './types/app/bsky/actor/searc
export * as AppBskyEmbedDefs from './types/app/bsky/embed/defs'
export * as AppBskyEmbedExternal from './types/app/bsky/embed/external'
export * as AppBskyEmbedImages from './types/app/bsky/embed/images'
export * as AppBskyEmbedPoll from './types/app/bsky/embed/poll'
export * as AppBskyEmbedRecord from './types/app/bsky/embed/record'
export * as AppBskyEmbedRecordWithMedia from './types/app/bsky/embed/recordWithMedia'
export * as AppBskyEmbedVideo from './types/app/bsky/embed/video'
Expand All @@ -339,13 +343,15 @@ export * as AppBskyFeedGetFeedGenerators from './types/app/bsky/feed/getFeedGene
export * as AppBskyFeedGetFeedSkeleton from './types/app/bsky/feed/getFeedSkeleton'
export * as AppBskyFeedGetLikes from './types/app/bsky/feed/getLikes'
export * as AppBskyFeedGetListFeed from './types/app/bsky/feed/getListFeed'
export * as AppBskyFeedGetPollAnswers from './types/app/bsky/feed/getPollAnswers'
export * as AppBskyFeedGetPostThread from './types/app/bsky/feed/getPostThread'
export * as AppBskyFeedGetPosts from './types/app/bsky/feed/getPosts'
export * as AppBskyFeedGetQuotes from './types/app/bsky/feed/getQuotes'
export * as AppBskyFeedGetRepostedBy from './types/app/bsky/feed/getRepostedBy'
export * as AppBskyFeedGetSuggestedFeeds from './types/app/bsky/feed/getSuggestedFeeds'
export * as AppBskyFeedGetTimeline from './types/app/bsky/feed/getTimeline'
export * as AppBskyFeedLike from './types/app/bsky/feed/like'
export * as AppBskyFeedPollAnswer from './types/app/bsky/feed/pollAnswer'
export * as AppBskyFeedPost from './types/app/bsky/feed/post'
export * as AppBskyFeedPostgate from './types/app/bsky/feed/postgate'
export * as AppBskyFeedRepost from './types/app/bsky/feed/repost'
Expand Down Expand Up @@ -1671,6 +1677,7 @@ export class AppBskyFeedNS {
_client: XrpcClient
generator: GeneratorRecord
like: LikeRecord
pollAnswer: PollAnswerRecord
post: PostRecord
postgate: PostgateRecord
repost: RepostRecord
Expand All @@ -1680,6 +1687,7 @@ export class AppBskyFeedNS {
this._client = client
this.generator = new GeneratorRecord(client)
this.like = new LikeRecord(client)
this.pollAnswer = new PollAnswerRecord(client)
this.post = new PostRecord(client)
this.postgate = new PostgateRecord(client)
this.repost = new RepostRecord(client)
Expand Down Expand Up @@ -1796,6 +1804,18 @@ export class AppBskyFeedNS {
})
}

getPollAnswers(
params?: AppBskyFeedGetPollAnswers.QueryParams,
opts?: AppBskyFeedGetPollAnswers.CallOptions,
): Promise<AppBskyFeedGetPollAnswers.Response> {
return this._client.call(
'app.bsky.feed.getPollAnswers',
params,
undefined,
opts,
)
}

getPostThread(
params?: AppBskyFeedGetPostThread.QueryParams,
opts?: AppBskyFeedGetPostThread.CallOptions,
Expand Down Expand Up @@ -2003,6 +2023,71 @@ export class LikeRecord {
}
}

export class PollAnswerRecord {
_client: XrpcClient

constructor(client: XrpcClient) {
this._client = client
}

async list(
params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>,
): Promise<{
cursor?: string
records: { uri: string; value: AppBskyFeedPollAnswer.Record }[]
}> {
const res = await this._client.call('com.atproto.repo.listRecords', {
collection: 'app.bsky.feed.pollAnswer',
...params,
})
return res.data
}

async get(
params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>,
): Promise<{
uri: string
cid: string
value: AppBskyFeedPollAnswer.Record
}> {
const res = await this._client.call('com.atproto.repo.getRecord', {
collection: 'app.bsky.feed.pollAnswer',
...params,
})
return res.data
}

async create(
params: Omit<
ComAtprotoRepoCreateRecord.InputSchema,
'collection' | 'record'
>,
record: AppBskyFeedPollAnswer.Record,
headers?: Record<string, string>,
): Promise<{ uri: string; cid: string }> {
record.$type = 'app.bsky.feed.pollAnswer'
const res = await this._client.call(
'com.atproto.repo.createRecord',
undefined,
{ collection: 'app.bsky.feed.pollAnswer', ...params, record },
{ encoding: 'application/json', headers },
)
return res.data
}

async delete(
params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>,
headers?: Record<string, string>,
): Promise<void> {
await this._client.call(
'com.atproto.repo.deleteRecord',
undefined,
{ collection: 'app.bsky.feed.pollAnswer', ...params },
{ headers },
)
}
}

export class PostRecord {
_client: XrpcClient

Expand Down
Loading