From ac481925622df9bf8024d1b5726282d0214fd22b Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Tue, 12 Dec 2023 13:47:07 +0000
Subject: [PATCH] [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
---
 internal/ap/extract.go                   | 78 +++++++++++++++--------
 internal/api/model/poll.go               |  6 +-
 internal/router/template.go              |  9 +++
 internal/typeutils/internaltofrontend.go | 81 +++++++++++++-----------
 web/template/status_poll.tmpl            |  4 ++
 5 files changed, 111 insertions(+), 67 deletions(-)

diff --git a/internal/ap/extract.go b/internal/ap/extract.go
index 987ff5e55..11c20494c 100644
--- a/internal/ap/extract.go
+++ b/internal/ap/extract.go
@@ -1102,19 +1102,11 @@ func ExtractPoll(poll Pollable) (*gtsmodel.Poll, error) {
 	var closed time.Time
 
 	// 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 {
 		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,
 	// it's okay for this to be zero.
 	closedSlice := GetClosed(poll)
@@ -1138,53 +1130,87 @@ func ExtractPoll(poll Pollable) (*gtsmodel.Poll, error) {
 	voters := GetVotersCount(poll)
 
 	return &gtsmodel.Poll{
-		Options:    options,
+		Options:    optionNames(options),
 		Multiple:   &multi,
 		HideCounts: &hideCounts,
-		Votes:      votes,
+		Votes:      optionVotes(options),
 		Voters:     &voters,
 		ExpiresAt:  endTime,
 		ClosedAt:   closed,
 	}, nil
 }
 
-// ExtractPollOptions extracts poll option name strings, and the 'multiple choice flag' property value from Pollable.
-func ExtractPollOptions(poll Pollable) (names []string, votes []int, multi bool, err error) {
+// pollOption is a simple type
+// 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
 
 	// Iterate the oneOf property and gather poll single-choice options.
 	IterateOneOf(poll, func(iter vocab.ActivityStreamsOneOfPropertyIterator) {
-		name, count, err := extractPollOption(iter.GetType())
+		name, votes, err := extractPollOption(iter.GetType())
 		if err != nil {
 			errs.Append(err)
 			return
 		}
-		names = append(names, name)
-		if count != nil {
-			votes = append(votes, *count)
+		if votes == nil {
+			hide = true
+			votes = new(int)
 		}
+		options = append(options, pollOption{
+			Name:  name,
+			Votes: *votes,
+		})
 	})
-	if len(names) > 0 || len(errs) > 0 {
-		return names, votes, false, errs.Combine()
+	if len(options) > 0 || len(errs) > 0 {
+		return options, false, hide, errs.Combine()
 	}
 
 	// Iterate the anyOf property and gather poll multi-choice options.
 	IterateAnyOf(poll, func(iter vocab.ActivityStreamsAnyOfPropertyIterator) {
-		name, count, err := extractPollOption(iter.GetType())
+		name, votes, err := extractPollOption(iter.GetType())
 		if err != nil {
 			errs.Append(err)
 			return
 		}
-		names = append(names, name)
-		if count != nil {
-			votes = append(votes, *count)
+		if votes == nil {
+			hide = true
+			votes = new(int)
 		}
+		options = append(options, pollOption{
+			Name:  name,
+			Votes: *votes,
+		})
 	})
-	if len(names) > 0 || len(errs) > 0 {
-		return names, votes, true, errs.Combine()
+	if len(options) > 0 || len(errs) > 0 {
+		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.
diff --git a/internal/api/model/poll.go b/internal/api/model/poll.go
index 3eb801998..5603ff222 100644
--- a/internal/api/model/poll.go
+++ b/internal/api/model/poll.go
@@ -28,7 +28,7 @@ type Poll struct {
 	ID string `json:"id"`
 
 	// When the poll ends. (ISO 8601 Datetime).
-	ExpiresAt string `json:"expires_at"`
+	ExpiresAt *string `json:"expires_at"`
 
 	// Is the poll currently expired?
 	Expired bool `json:"expired"`
@@ -40,7 +40,7 @@ type Poll struct {
 	VotesCount int `json:"votes_count"`
 
 	// 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?
 	//
@@ -68,7 +68,7 @@ type PollOption struct {
 	Title string `json:"title"`
 
 	// 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.
diff --git a/internal/router/template.go b/internal/router/template.go
index 804f532bd..d5e36d6f2 100644
--- a/internal/router/template.go
+++ b/internal/router/template.go
@@ -24,6 +24,7 @@ import (
 	"path/filepath"
 	"strings"
 	"time"
+	"unsafe"
 
 	"github.com/gin-gonic/gin"
 	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
@@ -172,6 +173,13 @@ func increment(i int) int {
 	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) {
 	engine.SetFuncMap(template.FuncMap{
 		"escape":           escape,
@@ -185,5 +193,6 @@ func LoadTemplateFunctions(engine *gin.Engine) {
 		"emojify":          emojify,
 		"acctInstance":     acctInstance,
 		"increment":        increment,
+		"isNil":            isNil,
 	})
 }
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index b91b2ad34..353c719b5 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -686,9 +686,9 @@ func (c *Converter) StatusToWebStatus(
 		webPollOptions := make([]apimodel.WebPollOption, len(poll.Options))
 		for i, option := range poll.Options {
 			var voteShare float32
-			if totalVotes != 0 &&
-				option.VotesCount != 0 {
-				voteShare = (float32(option.VotesCount) / float32(totalVotes)) * 100
+
+			if totalVotes != 0 && option.VotesCount != nil {
+				voteShare = float32(*option.VotesCount) / float32(totalVotes) * 100
 			}
 
 			// Format to two decimal points and ditch any
@@ -1432,11 +1432,11 @@ func (c *Converter) PollToAPIPoll(ctx context.Context, requester *gtsmodel.Accou
 	var (
 		options     []apimodel.PollOption
 		totalVotes  int
-		totalVoters int
-		voted       *bool
+		totalVoters *int
+		hasVoted    *bool
 		ownChoices  *[]int
 		isAuthor    bool
-		expiresAt   string
+		expiresAt   *string
 		emojis      []apimodel.Emoji
 	)
 
@@ -1462,57 +1462,62 @@ func (c *Converter) PollToAPIPoll(ctx context.Context, requester *gtsmodel.Accou
 			// Set choices by requester.
 			ownChoices = &vote.Choices
 
-			// Update default totals in the
-			// case that counts are hidden.
+			// Update default total in the
+			// case that counts are hidden
+			// (so we just show our own).
 			totalVotes = len(vote.Choices)
-			totalVoters = 1
-			for _, choice := range *ownChoices {
-				options[choice].VotesCount++
-			}
 		} else {
-			// Requester is defined but hasn't made
-			// a choice. Init slice to serialize as `[]`.
-			ownChoices = util.Ptr(make([]int, 0))
+			// Requester hasn't yet voted, use
+			// empty slice to serialize as `[]`.
+			ownChoices = &[]int{}
 		}
 
 		// Check if requester is author of source status.
 		isAuthor = (requester.ID == poll.Status.AccountID)
 
-		// Requester is defined so voted should be defined too.
-		voted = util.Ptr((isAuthor || len(*ownChoices) > 0))
+		// Set whether requester has voted in poll (or = author).
+		hasVoted = util.Ptr((isAuthor || len(*ownChoices) > 0))
 	}
 
 	if isAuthor || !*poll.HideCounts {
-		// A remote status,
-		// the simple route!
-		//
-		// Pull cached remote values.
-		totalVoters = (*poll.Voters)
+		// Only in the case that hide counts is
+		// disabled, or the requester is the author
+		// do we actually populate the vote counts.
 
-		// When this is status author, or hide counts
-		// is disabled, set the counts known per vote,
-		// and accumulate all the vote totals.
+		if *poll.Multiple {
+			// The total number of voters are only
+			// 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 {
-			options[i].VotesCount = count
+			if options[i].VotesCount == nil {
+				options[i].VotesCount = new(int)
+			}
+			(*options[i].VotesCount) += count
 			totalVotes += count
 		}
 	}
 
 	if !poll.ExpiresAt.IsZero() {
 		// Calculate poll expiry string (if set).
-		expiresAt = util.FormatISO8601(poll.ExpiresAt)
+		str := util.FormatISO8601(poll.ExpiresAt)
+		expiresAt = &str
 	}
 
-	// Try to inherit emojis
-	// from parent status.
-	if pStatus := poll.Status; pStatus != nil {
-		var err error
-		emojis, err = c.convertEmojisToAPIEmojis(ctx, pStatus.Emojis, pStatus.EmojiIDs)
-		if err != nil {
-			// Fall back to empty slice.
-			log.Errorf(ctx, "error converting emojis from parent status: %v", err)
-			emojis = make([]apimodel.Emoji, 0)
-		}
+	var err error
+
+	// Try to inherit emojis from parent status.
+	emojis, err = c.convertEmojisToAPIEmojis(ctx,
+		poll.Status.Emojis,
+		poll.Status.EmojiIDs,
+	)
+	if err != nil {
+		log.Errorf(ctx, "error converting emojis from parent status: %v", err)
+		emojis = []apimodel.Emoji{} // fallback to empty slice.
 	}
 
 	return &apimodel.Poll{
@@ -1522,7 +1527,7 @@ func (c *Converter) PollToAPIPoll(ctx context.Context, requester *gtsmodel.Accou
 		Multiple:    (*poll.Multiple),
 		VotesCount:  totalVotes,
 		VotersCount: totalVoters,
-		Voted:       voted,
+		Voted:       hasVoted,
 		OwnVotes:    ownChoices,
 		Options:     options,
 		Emojis:      emojis,
diff --git a/web/template/status_poll.tmpl b/web/template/status_poll.tmpl
index f6acecfa0..d26046283 100644
--- a/web/template/status_poll.tmpl
+++ b/web/template/status_poll.tmpl
@@ -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>
 				<div class="sr-only">Option {{ increment $index }}:&nbsp;<span lang="{{ .LanguageTag.TagStr }}">{{ emojify .Emojis (noescape $pollOption.Title) -}}</span></div>
 				<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-count">
 						{{- if eq $pollOption.VotesCount 1 -}}
@@ -55,6 +58,7 @@
 							{{- $pollOption.VotesCount }} votes
 						{{- end -}}
 					</span>
+					{{- end -}}
 				</div>
 			</li>
 		{{- end }}