feat: view polls and add votes to them

Add support for viewing and voting in polls.
This commit is contained in:
Dan Anglin 2024-06-13 19:02:31 +01:00
parent ac2e74cac3
commit b06e92f26b
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
8 changed files with 342 additions and 60 deletions

55
internal/client/poll.go Normal file
View file

@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
const (
pollPath string = "/api/v1/polls"
)
func (g *Client) GetPoll(pollID string) (model.Poll, error) {
url := g.Authentication.Instance + pollPath + "/" + pollID
var poll model.Poll
if err := g.sendRequest(http.MethodGet, url, nil, &poll); err != nil {
return model.Poll{}, fmt.Errorf(
"received an error after sending the request to get the poll: %w",
err,
)
}
return poll, nil
}
func (g *Client) VoteInPoll(pollID string, choices []int) error {
form := struct {
Choices []int `json:"choices"`
}{
Choices: choices,
}
data, err := json.Marshal(form)
if err != nil {
return fmt.Errorf("unable to encode the JSON form: %w", err)
}
requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + pollPath + "/" + pollID + "/votes"
if err := g.sendRequest(http.MethodPost, url, requestBody, nil); err != nil {
return fmt.Errorf("received an error after sending the request to vote in the poll: %w", err)
}
return nil
}

View file

@ -5,6 +5,7 @@
package executor
import (
"errors"
"flag"
"fmt"
@ -19,6 +20,8 @@ type AddExecutor struct {
toResourceType string
listID string
statusID string
pollID string
choices PollChoices
accountNames AccountNames
content string
}
@ -34,10 +37,12 @@ func NewAddExecutor(tlf TopLevelFlags, name, summary string) *AddExecutor {
addExe.StringVar(&addExe.resourceType, flagType, "", "Specify the resource type to add (e.g. account, note)")
addExe.StringVar(&addExe.toResourceType, flagTo, "", "Specify the target resource type to add to (e.g. list, account, etc)")
addExe.StringVar(&addExe.listID, flagListID, "", "The ID of the list to add to")
addExe.StringVar(&addExe.listID, flagListID, "", "The ID of the list")
addExe.StringVar(&addExe.statusID, flagStatusID, "", "The ID of the status")
addExe.Var(&addExe.accountNames, flagAccountName, "The name of the account")
addExe.StringVar(&addExe.content, flagContent, "", "The content of the resource")
addExe.StringVar(&addExe.pollID, flagPollID, "", "The ID of the poll")
addExe.Var(&addExe.accountNames, flagAccountName, "The name of the account")
addExe.Var(&addExe.choices, flagChoose, "Specify your choice ")
addExe.Usage = commandUsageFunc(name, summary, addExe.FlagSet)
@ -54,6 +59,7 @@ func (a *AddExecutor) Execute() error {
resourceAccount: a.addToAccount,
resourceBookmarks: a.addToBookmarks,
resourceStatus: a.addToStatus,
resourcePoll: a.addToPoll,
}
doFunc, ok := funcMap[a.toResourceType]
@ -227,3 +233,50 @@ func (a *AddExecutor) addBoostToStatus(gtsClient *client.Client) error {
return nil
}
func (a *AddExecutor) addToPoll(gtsClient *client.Client) error {
if a.pollID == "" {
return FlagNotSetError{flagText: flagPollID}
}
funcMap := map[string]func(*client.Client) error{
resourceVote: a.addVoteToPoll,
}
doFunc, ok := funcMap[a.resourceType]
if !ok {
return UnsupportedAddOperationError{
ResourceType: a.resourceType,
AddToResourceType: a.toResourceType,
}
}
return doFunc(gtsClient)
}
func (a *AddExecutor) addVoteToPoll(gtsClient *client.Client) error {
if len(a.choices) == 0 {
return errors.New("please use --" + flagChoose + " to make a choice in this poll")
}
poll, err := gtsClient.GetPoll(a.pollID)
if err != nil {
return fmt.Errorf("unable to retrieve the poll: %w", err)
}
if poll.Expired {
return ExpiredPollError{}
}
if !poll.Multiple && len(a.choices) > 1 {
return MultipleChoiceError{}
}
if err := gtsClient.VoteInPoll(a.pollID, []int(a.choices)); err != nil {
return fmt.Errorf("unable to add your vote(s) to the poll: %w", err)
}
fmt.Println("Successfully added your vote(s) to the poll.")
return nil
}

View file

@ -66,3 +66,15 @@ type UnknownCommandError struct {
func (e UnknownCommandError) Error() string {
return "unknown command '" + e.Command + "'"
}
type ExpiredPollError struct{}
func (e ExpiredPollError) Error() string {
return "this poll has expired"
}
type MultipleChoiceError struct{}
func (e MultipleChoiceError) Error() string {
return "this poll does not allow multiple choices"
}

View file

@ -4,11 +4,16 @@
package executor
import "strings"
import (
"fmt"
"strconv"
"strings"
)
const (
flagAccountName = "account-name"
flagBrowser = "browser"
flagChoose = "choose"
flagContentType = "content-type"
flagContent = "content"
flagEnableFederation = "enable-federation"
@ -26,6 +31,7 @@ const (
flagListRepliesPolicy = "list-replies-policy"
flagMyAccount = "my-account"
flagNotify = "notify"
flagPollID = "poll-id"
flagSensitive = "sensitive"
flagSkipRelationship = "skip-relationship"
flagShowPreferences = "show-preferences"
@ -58,3 +64,30 @@ type TopLevelFlags struct {
NoColor *bool
Pager string
}
type PollChoices []int
func (p *PollChoices) String() string {
value := "Choices: "
for ind, vote := range *p {
if ind == len(*p)-1 {
value += strconv.Itoa(vote)
} else {
value += strconv.Itoa(vote) + ", "
}
}
return value
}
func (p *PollChoices) Set(text string) error {
value, err := strconv.Atoi(text)
if err != nil {
return fmt.Errorf("unable to parse the value to an integer: %w", err)
}
*p = append(*p, value)
return nil
}

View file

@ -4,7 +4,7 @@
package executor
const(
const (
resourceAccount = "account"
resourceBlocked = "blocked"
resourceBookmarks = "bookmarks"
@ -17,8 +17,10 @@ const(
resourceLiked = "liked"
resourceList = "list"
resourceNote = "note"
resourcePoll = "poll"
resourceStatus = "status"
resourceStar = "star"
resourceStarred = "starred"
resourceTimeline = "timeline"
resourceVote = "vote"
)

View file

@ -26,6 +26,7 @@ type ShowExecutor struct {
timelineCategory string
listID string
tag string
pollID string
limit int
}
@ -45,6 +46,7 @@ func NewShowExecutor(tlf TopLevelFlags, name, summary string) *ShowExecutor {
showExe.StringVar(&showExe.timelineCategory, flagTimelineCategory, model.TimelineCategoryHome, "Specify the timeline category to view")
showExe.StringVar(&showExe.listID, flagListID, "", "Specify the ID of the list to display")
showExe.StringVar(&showExe.tag, flagTag, "", "Specify the name of the tag to use")
showExe.StringVar(&showExe.pollID, flagPollID, "", "Specify the ID of the poll to display")
showExe.IntVar(&showExe.limit, flagLimit, 20, "Specify the limit of items to display")
showExe.Usage = commandUsageFunc(name, summary, showExe.FlagSet)
@ -70,6 +72,7 @@ func (s *ShowExecutor) Execute() error {
resourceLiked: s.showLiked,
resourceStarred: s.showLiked,
resourceFollowRequest: s.showFollowRequests,
resourcePoll: s.showPoll,
}
doFunc, ok := funcMap[s.resourceType]
@ -362,3 +365,18 @@ func (s *ShowExecutor) showFollowRequests(gtsClient *client.Client) error {
return nil
}
func (s *ShowExecutor) showPoll(gtsClient *client.Client) error {
if s.pollID == "" {
return FlagNotSetError{flagText: flagPollID}
}
poll, err := gtsClient.GetPoll(s.pollID)
if err != nil {
return fmt.Errorf("unable to retrieve the poll: %w", err)
}
utilities.Display(poll, *s.topLevelFlags.NoColor, s.topLevelFlags.Pager)
return nil
}

119
internal/model/poll.go Normal file
View file

@ -0,0 +1,119 @@
package model
import (
"io"
"math"
"strconv"
"strings"
"time"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type Poll struct {
Emojis []Emoji `json:"emojis"`
Expired bool `json:"expired"`
Voted bool `json:"voted"`
Multiple bool `json:"multiple"`
ExpiredAt time.Time `json:"expires_at"`
ID string `json:"id"`
OwnVotes []int `json:"own_votes"`
VotersCount int `json:"voters_count"`
VotesCount int `json:"votes_count"`
Options []PollOption `json:"options"`
}
type PollOption struct {
Title string `json:"title"`
VotesCount int `json:"votes_count"`
}
func (p Poll) Display(noColor bool) string {
var builder strings.Builder
indent := " "
builder.WriteString(
utilities.HeaderFormat(noColor, "POLL ID:") +
"\n" + indent + p.ID +
"\n\n" + utilities.HeaderFormat(noColor, "OPTIONS:"),
)
displayPollContent(&builder, p, noColor, indent)
builder.WriteString(
"\n\n" +
utilities.HeaderFormat(noColor, "MULTIPLE CHOICES ALLOWED:") +
"\n" + indent + strconv.FormatBool(p.Multiple) +
"\n\n" +
utilities.HeaderFormat(noColor, "YOU VOTED:") +
"\n" + indent + strconv.FormatBool(p.Voted),
)
if len(p.OwnVotes) > 0 {
builder.WriteString("\n\n" + utilities.HeaderFormat(noColor, "YOUR VOTES:"))
for _, vote := range p.OwnVotes {
builder.WriteString("\n" + indent + "[" + strconv.Itoa(vote) + "] " + p.Options[vote].Title)
}
}
builder.WriteString(
"\n\n" +
utilities.HeaderFormat(noColor, "EXPIRED:") +
"\n" + indent + strconv.FormatBool(p.Expired),
)
return builder.String()
}
func displayPollContent(writer io.StringWriter, poll Poll, noColor bool, indent string) {
for ind, option := range poll.Options {
var percentage int
var calculate float64
if poll.VotesCount == 0 {
percentage = 0
} else {
calculate = float64(option.VotesCount) / float64(poll.VotesCount)
percentage = int(math.Floor(100 * calculate))
}
writer.WriteString("\n\n" + indent + "[" + strconv.Itoa(ind) + "] " + option.Title)
drawPollMeter(writer, noColor, calculate, 80, indent)
writer.WriteString(
"\n" + indent + strconv.Itoa(option.VotesCount) + " votes " +
"(" + strconv.Itoa(percentage) + "%)",
)
}
writer.WriteString(
"\n\n" +
indent + utilities.FieldFormat(noColor, "Total votes:") + " " + strconv.Itoa(poll.VotesCount) +
"\n" + indent + utilities.FieldFormat(noColor, "Poll ID:") + " " + poll.ID +
"\n" + indent + utilities.FieldFormat(noColor, "Poll is open until:") + " " + utilities.FormatTime(poll.ExpiredAt),
)
}
func drawPollMeter(writer io.StringWriter, noColor bool, calculated float64, limit int, indent string) {
numVoteBlocks := int(math.Floor(float64(limit) * calculated))
numBackgroundBlocks := limit - numVoteBlocks
blockChar := "\u2501"
voteBlockColor := "\033[32;1m"
backgroundBlockColor := "\033[90m"
if noColor {
voteBlockColor = "\033[0m"
if numVoteBlocks == 0 {
numVoteBlocks = 1
}
}
writer.WriteString("\n" + indent + voteBlockColor + strings.Repeat(blockChar, numVoteBlocks) + "\033[0m")
if !noColor {
writer.WriteString(backgroundBlockColor + strings.Repeat(blockChar, numBackgroundBlocks) + "\033[0m")
}
}

View file

@ -5,7 +5,7 @@
package model
import (
"fmt"
"strconv"
"strings"
"time"
@ -30,7 +30,7 @@ type Status struct {
Mentions []Mention `json:"mentions"`
Muted bool `json:"muted"`
Pinned bool `json:"pinned"`
Poll Poll `json:"poll"`
Poll *Poll `json:"poll"`
Reblog *StatusReblogged `json:"reblog"`
Reblogged bool `json:"reblogged"`
ReblogsCount int `json:"reblogs_count"`
@ -68,24 +68,6 @@ type Mention struct {
Username string `json:"username"`
}
type Poll struct {
Emojis []Emoji `json:"emojis"`
Expired bool `json:"expired"`
Voted bool `json:"voted"`
Multiple bool `json:"multiple"`
ExpiredAt time.Time `json:"expires_at"`
ID string `json:"id"`
OwnVotes []int `json:"own_votes"`
VotersCount int `json:"voters_count"`
VotesCount int `json:"votes_count"`
Options []PollOption `json:"options"`
}
type PollOption struct {
Title string `json:"title"`
VotesCount int `json:"votes_count"`
}
type StatusReblogged struct {
Account Account `json:"account"`
Application Application `json:"application"`
@ -158,47 +140,44 @@ type MediaDimensions struct {
}
func (s Status) Display(noColor bool) string {
format := `
%s
indent := " "
%s
%s
%s
%s
var builder strings.Builder
%s
%s
// The account information
builder.WriteString(utilities.FullDisplayNameFormat(noColor, s.Account.DisplayName, s.Account.Acct) + "\n\n")
%s
Boosts: %d
Likes: %d
Replies: %d
// The content of the status.
builder.WriteString(utilities.HeaderFormat(noColor, "CONTENT:"))
builder.WriteString(utilities.WrapLines(utilities.ConvertHTMLToText(s.Content), "\n ", 80))
%s
%s
// If a poll exists in a status, write the contents to the builder.
if s.Poll != nil {
displayPollContent(&builder, *s.Poll, noColor, indent)
}
%s
%s
`
// The ID of the status
builder.WriteString("\n\n" + utilities.HeaderFormat(noColor, "STATUS ID:") + "\n" + indent + s.ID)
return fmt.Sprintf(
format,
utilities.FullDisplayNameFormat(noColor, s.Account.DisplayName, s.Account.Acct),
utilities.HeaderFormat(noColor, "CONTENT:"),
utilities.WrapLines(utilities.ConvertHTMLToText(s.Content), "\n ", 80),
utilities.HeaderFormat(noColor, "STATUS ID:"),
s.ID,
utilities.HeaderFormat(noColor, "CREATED AT:"),
utilities.FormatTime(s.CreatedAt),
utilities.HeaderFormat(noColor, "STATS:"),
s.ReblogsCount,
s.FavouritesCount,
s.RepliesCount,
utilities.HeaderFormat(noColor, "VISIBILITY:"),
s.Visibility,
utilities.HeaderFormat(noColor, "URL:"),
s.URL,
// Status creation time
builder.WriteString("\n\n" + utilities.HeaderFormat(noColor, "CREATED AT:") + "\n" + indent + utilities.FormatTime(s.CreatedAt))
// Status stats
builder.WriteString(
"\n\n" +
utilities.HeaderFormat(noColor, "STATS:") +
"\n" + indent + utilities.FieldFormat(noColor, "Boosts: ") + strconv.Itoa(s.ReblogsCount) +
"\n" + indent + utilities.FieldFormat(noColor, "Likes: ") + strconv.Itoa(s.FavouritesCount) +
"\n" + indent + utilities.FieldFormat(noColor, "Replies: ") + strconv.Itoa(s.RepliesCount),
)
// Status visibility
builder.WriteString("\n\n" + utilities.HeaderFormat(noColor, "VISIBILITY:") + "\n" + indent + s.Visibility.String())
// Status URL
builder.WriteString("\n\n" + utilities.HeaderFormat(noColor, "URL:") + "\n" + indent + s.URL)
return builder.String()
}
type StatusList struct {
@ -225,8 +204,19 @@ func (s StatusList) Display(noColor bool) string {
createdAt = status.Reblog.CreatedAt
}
builder.WriteString(utilities.WrapLines(utilities.ConvertHTMLToText(status.Content), "\n", 80) + "\n\n")
builder.WriteString(utilities.FieldFormat(noColor, "ID:") + " " + statusID + "\t" + utilities.FieldFormat(noColor, "Created at:") + " " + utilities.FormatTime(createdAt) + "\n")
builder.WriteString(utilities.WrapLines(utilities.ConvertHTMLToText(status.Content), "\n", 80))
if status.Poll != nil {
displayPollContent(&builder, *status.Poll, noColor, "")
}
builder.WriteString(
"\n\n" +
utilities.FieldFormat(noColor, "Status ID:") + " " + statusID + "\t" +
utilities.FieldFormat(noColor, "Created at:") + " " + utilities.FormatTime(createdAt) +
"\n",
)
builder.WriteString(separator + "\n")
}