[bugfix] poll vote count fixes (#2444)

* don't drop all vote counts if hideCounts is set, refactors poll option extraction slightly

* omit voters_count when not set

* make voters_count a ptr to ensure it is omit unless definitely needed

* handle case of expires_at, voters_count and option.votes_count being nilable

* faster isNil check

* remove omitempty tags since mastodon API marks things as nullable but still sets them in outgoing json
This commit is contained in:
kim 2023-12-12 13:47:07 +00:00 committed by GitHub
parent 2191c7dee5
commit ac48192562
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 111 additions and 67 deletions

View File

@ -1102,19 +1102,11 @@ func ExtractPoll(poll Pollable) (*gtsmodel.Poll, error) {
var closed time.Time var closed time.Time
// Extract the options (votes if any) and 'multiple choice' flag. // Extract the options (votes if any) and 'multiple choice' flag.
options, votes, multi, err := ExtractPollOptions(poll) options, multi, hideCounts, err := extractPollOptions(poll)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Check if counts have been hidden from us.
hideCounts := len(options) != len(votes)
if hideCounts {
// Simply provide zeroed slice.
votes = make([]int, len(options))
}
// Extract the poll closed time, // Extract the poll closed time,
// it's okay for this to be zero. // it's okay for this to be zero.
closedSlice := GetClosed(poll) closedSlice := GetClosed(poll)
@ -1138,53 +1130,87 @@ func ExtractPoll(poll Pollable) (*gtsmodel.Poll, error) {
voters := GetVotersCount(poll) voters := GetVotersCount(poll)
return &gtsmodel.Poll{ return &gtsmodel.Poll{
Options: options, Options: optionNames(options),
Multiple: &multi, Multiple: &multi,
HideCounts: &hideCounts, HideCounts: &hideCounts,
Votes: votes, Votes: optionVotes(options),
Voters: &voters, Voters: &voters,
ExpiresAt: endTime, ExpiresAt: endTime,
ClosedAt: closed, ClosedAt: closed,
}, nil }, nil
} }
// ExtractPollOptions extracts poll option name strings, and the 'multiple choice flag' property value from Pollable. // pollOption is a simple type
func ExtractPollOptions(poll Pollable) (names []string, votes []int, multi bool, err error) { // to unify a poll option name
// with the number of votes.
type pollOption struct {
Name string
Votes int
}
// optionNames extracts name strings from a slice of poll options.
func optionNames(in []pollOption) []string {
out := make([]string, len(in))
for i := range in {
out[i] = in[i].Name
}
return out
}
// optionVotes extracts vote counts from a slice of poll options.
func optionVotes(in []pollOption) []int {
out := make([]int, len(in))
for i := range in {
out[i] = in[i].Votes
}
return out
}
// extractPollOptions extracts poll option name strings, the 'multiple choice flag', and 'hideCounts' intrinsic flag properties value from Pollable.
func extractPollOptions(poll Pollable) (options []pollOption, multi bool, hide bool, err error) {
var errs gtserror.MultiError var errs gtserror.MultiError
// Iterate the oneOf property and gather poll single-choice options. // Iterate the oneOf property and gather poll single-choice options.
IterateOneOf(poll, func(iter vocab.ActivityStreamsOneOfPropertyIterator) { IterateOneOf(poll, func(iter vocab.ActivityStreamsOneOfPropertyIterator) {
name, count, err := extractPollOption(iter.GetType()) name, votes, err := extractPollOption(iter.GetType())
if err != nil { if err != nil {
errs.Append(err) errs.Append(err)
return return
} }
names = append(names, name) if votes == nil {
if count != nil { hide = true
votes = append(votes, *count) votes = new(int)
} }
options = append(options, pollOption{
Name: name,
Votes: *votes,
})
}) })
if len(names) > 0 || len(errs) > 0 { if len(options) > 0 || len(errs) > 0 {
return names, votes, false, errs.Combine() return options, false, hide, errs.Combine()
} }
// Iterate the anyOf property and gather poll multi-choice options. // Iterate the anyOf property and gather poll multi-choice options.
IterateAnyOf(poll, func(iter vocab.ActivityStreamsAnyOfPropertyIterator) { IterateAnyOf(poll, func(iter vocab.ActivityStreamsAnyOfPropertyIterator) {
name, count, err := extractPollOption(iter.GetType()) name, votes, err := extractPollOption(iter.GetType())
if err != nil { if err != nil {
errs.Append(err) errs.Append(err)
return return
} }
names = append(names, name) if votes == nil {
if count != nil { hide = true
votes = append(votes, *count) votes = new(int)
} }
options = append(options, pollOption{
Name: name,
Votes: *votes,
})
}) })
if len(names) > 0 || len(errs) > 0 { if len(options) > 0 || len(errs) > 0 {
return names, votes, true, errs.Combine() return options, true, hide, errs.Combine()
} }
return nil, nil, false, errors.New("poll without options") return nil, false, false, errors.New("poll without options")
} }
// IterateOneOf will attempt to extract oneOf property from given interface, and passes each iterated item to function. // IterateOneOf will attempt to extract oneOf property from given interface, and passes each iterated item to function.

View File

@ -28,7 +28,7 @@ type Poll struct {
ID string `json:"id"` ID string `json:"id"`
// When the poll ends. (ISO 8601 Datetime). // When the poll ends. (ISO 8601 Datetime).
ExpiresAt string `json:"expires_at"` ExpiresAt *string `json:"expires_at"`
// Is the poll currently expired? // Is the poll currently expired?
Expired bool `json:"expired"` Expired bool `json:"expired"`
@ -40,7 +40,7 @@ type Poll struct {
VotesCount int `json:"votes_count"` VotesCount int `json:"votes_count"`
// How many unique accounts have voted on a multiple-choice poll. // How many unique accounts have voted on a multiple-choice poll.
VotersCount int `json:"voters_count"` VotersCount *int `json:"voters_count"`
// When called with a user token, has the authorized user voted? // When called with a user token, has the authorized user voted?
// //
@ -68,7 +68,7 @@ type PollOption struct {
Title string `json:"title"` Title string `json:"title"`
// The number of received votes for this option. // The number of received votes for this option.
VotesCount int `json:"votes_count"` VotesCount *int `json:"votes_count"`
} }
// PollRequest models a request to create a poll. // PollRequest models a request to create a poll.

View File

@ -24,6 +24,7 @@
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"unsafe"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
@ -172,6 +173,13 @@ func increment(i int) int {
return i + 1 return i + 1
} }
// isNil will safely check if 'v' is nil without
// dealing with weird Go interface nil bullshit.
func isNil(i interface{}) bool {
type eface struct{ _, data unsafe.Pointer }
return (*eface)(unsafe.Pointer(&i)).data == nil
}
func LoadTemplateFunctions(engine *gin.Engine) { func LoadTemplateFunctions(engine *gin.Engine) {
engine.SetFuncMap(template.FuncMap{ engine.SetFuncMap(template.FuncMap{
"escape": escape, "escape": escape,
@ -185,5 +193,6 @@ func LoadTemplateFunctions(engine *gin.Engine) {
"emojify": emojify, "emojify": emojify,
"acctInstance": acctInstance, "acctInstance": acctInstance,
"increment": increment, "increment": increment,
"isNil": isNil,
}) })
} }

View File

@ -686,9 +686,9 @@ func (c *Converter) StatusToWebStatus(
webPollOptions := make([]apimodel.WebPollOption, len(poll.Options)) webPollOptions := make([]apimodel.WebPollOption, len(poll.Options))
for i, option := range poll.Options { for i, option := range poll.Options {
var voteShare float32 var voteShare float32
if totalVotes != 0 &&
option.VotesCount != 0 { if totalVotes != 0 && option.VotesCount != nil {
voteShare = (float32(option.VotesCount) / float32(totalVotes)) * 100 voteShare = float32(*option.VotesCount) / float32(totalVotes) * 100
} }
// Format to two decimal points and ditch any // Format to two decimal points and ditch any
@ -1432,11 +1432,11 @@ func (c *Converter) PollToAPIPoll(ctx context.Context, requester *gtsmodel.Accou
var ( var (
options []apimodel.PollOption options []apimodel.PollOption
totalVotes int totalVotes int
totalVoters int totalVoters *int
voted *bool hasVoted *bool
ownChoices *[]int ownChoices *[]int
isAuthor bool isAuthor bool
expiresAt string expiresAt *string
emojis []apimodel.Emoji emojis []apimodel.Emoji
) )
@ -1462,57 +1462,62 @@ func (c *Converter) PollToAPIPoll(ctx context.Context, requester *gtsmodel.Accou
// Set choices by requester. // Set choices by requester.
ownChoices = &vote.Choices ownChoices = &vote.Choices
// Update default totals in the // Update default total in the
// case that counts are hidden. // case that counts are hidden
// (so we just show our own).
totalVotes = len(vote.Choices) totalVotes = len(vote.Choices)
totalVoters = 1
for _, choice := range *ownChoices {
options[choice].VotesCount++
}
} else { } else {
// Requester is defined but hasn't made // Requester hasn't yet voted, use
// a choice. Init slice to serialize as `[]`. // empty slice to serialize as `[]`.
ownChoices = util.Ptr(make([]int, 0)) ownChoices = &[]int{}
} }
// Check if requester is author of source status. // Check if requester is author of source status.
isAuthor = (requester.ID == poll.Status.AccountID) isAuthor = (requester.ID == poll.Status.AccountID)
// Requester is defined so voted should be defined too. // Set whether requester has voted in poll (or = author).
voted = util.Ptr((isAuthor || len(*ownChoices) > 0)) hasVoted = util.Ptr((isAuthor || len(*ownChoices) > 0))
} }
if isAuthor || !*poll.HideCounts { if isAuthor || !*poll.HideCounts {
// A remote status, // Only in the case that hide counts is
// the simple route! // disabled, or the requester is the author
// // do we actually populate the vote counts.
// Pull cached remote values.
totalVoters = (*poll.Voters)
// When this is status author, or hide counts if *poll.Multiple {
// is disabled, set the counts known per vote, // The total number of voters are only
// and accumulate all the vote totals. // provided in the case of a multiple
// choice poll. All else leaves it nil.
totalVoters = poll.Voters
}
// Populate per-vote counts
// and overall total vote count.
for i, count := range poll.Votes { for i, count := range poll.Votes {
options[i].VotesCount = count if options[i].VotesCount == nil {
options[i].VotesCount = new(int)
}
(*options[i].VotesCount) += count
totalVotes += count totalVotes += count
} }
} }
if !poll.ExpiresAt.IsZero() { if !poll.ExpiresAt.IsZero() {
// Calculate poll expiry string (if set). // Calculate poll expiry string (if set).
expiresAt = util.FormatISO8601(poll.ExpiresAt) str := util.FormatISO8601(poll.ExpiresAt)
expiresAt = &str
} }
// Try to inherit emojis var err error
// from parent status.
if pStatus := poll.Status; pStatus != nil { // Try to inherit emojis from parent status.
var err error emojis, err = c.convertEmojisToAPIEmojis(ctx,
emojis, err = c.convertEmojisToAPIEmojis(ctx, pStatus.Emojis, pStatus.EmojiIDs) poll.Status.Emojis,
if err != nil { poll.Status.EmojiIDs,
// Fall back to empty slice. )
log.Errorf(ctx, "error converting emojis from parent status: %v", err) if err != nil {
emojis = make([]apimodel.Emoji, 0) log.Errorf(ctx, "error converting emojis from parent status: %v", err)
} emojis = []apimodel.Emoji{} // fallback to empty slice.
} }
return &apimodel.Poll{ return &apimodel.Poll{
@ -1522,7 +1527,7 @@ func (c *Converter) PollToAPIPoll(ctx context.Context, requester *gtsmodel.Accou
Multiple: (*poll.Multiple), Multiple: (*poll.Multiple),
VotesCount: totalVotes, VotesCount: totalVotes,
VotersCount: totalVoters, VotersCount: totalVoters,
Voted: voted, Voted: hasVoted,
OwnVotes: ownChoices, OwnVotes: ownChoices,
Options: options, Options: options,
Emojis: emojis, Emojis: emojis,

View File

@ -47,6 +47,9 @@
<meter aria-hidden="true" id="poll-{{- $pollOption.PollID -}}-option-{{- increment $index -}}" min="0" max="100" value="{{- $pollOption.VoteShare -}}">{{- $pollOption.VoteShare -}}&#37;</meter> <meter aria-hidden="true" id="poll-{{- $pollOption.PollID -}}-option-{{- increment $index -}}" min="0" max="100" value="{{- $pollOption.VoteShare -}}">{{- $pollOption.VoteShare -}}&#37;</meter>
<div class="sr-only">Option {{ increment $index }}:&nbsp;<span lang="{{ .LanguageTag.TagStr }}">{{ emojify .Emojis (noescape $pollOption.Title) -}}</span></div> <div class="sr-only">Option {{ increment $index }}:&nbsp;<span lang="{{ .LanguageTag.TagStr }}">{{ emojify .Emojis (noescape $pollOption.Title) -}}</span></div>
<div class="poll-vote-summary"> <div class="poll-vote-summary">
{{- if isNil $pollOption.VotesCount }}
Results not yet published.
{{- else -}}
<span class="poll-vote-share">{{- $pollOption.VoteShareStr -}}&#37;</span> <span class="poll-vote-share">{{- $pollOption.VoteShareStr -}}&#37;</span>
<span class="poll-vote-count"> <span class="poll-vote-count">
{{- if eq $pollOption.VotesCount 1 -}} {{- if eq $pollOption.VotesCount 1 -}}
@ -55,6 +58,7 @@
{{- $pollOption.VotesCount }} votes {{- $pollOption.VotesCount }} votes
{{- end -}} {{- end -}}
</span> </span>
{{- end -}}
</div> </div>
</li> </li>
{{- end }} {{- end }}