mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-30 22:42:24 -05:00 
			
		
		
		
	[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:
		
					parent
					
						
							
								2191c7dee5
							
						
					
				
			
			
				commit
				
					
						ac48192562
					
				
			
		
					 5 changed files with 111 additions and 67 deletions
				
			
		|  | @ -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 >smodel.Poll{ | 	return >smodel.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 { | 	}) | ||||||
| 		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. | 	// 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 { | 	}) | ||||||
| 		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. | // IterateOneOf will attempt to extract oneOf property from given interface, and passes each iterated item to function. | ||||||
|  |  | ||||||
|  | @ -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. | ||||||
|  |  | ||||||
|  | @ -24,6 +24,7 @@ import ( | ||||||
| 	"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, | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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 |  | ||||||
| 	// from parent status. |  | ||||||
| 	if pStatus := poll.Status; pStatus != nil { |  | ||||||
| 	var err error | 	var err error | ||||||
| 		emojis, err = c.convertEmojisToAPIEmojis(ctx, pStatus.Emojis, pStatus.EmojiIDs) | 
 | ||||||
|  | 	// Try to inherit emojis from parent status. | ||||||
|  | 	emojis, err = c.convertEmojisToAPIEmojis(ctx, | ||||||
|  | 		poll.Status.Emojis, | ||||||
|  | 		poll.Status.EmojiIDs, | ||||||
|  | 	) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 			// Fall back to empty slice. |  | ||||||
| 		log.Errorf(ctx, "error converting emojis from parent status: %v", err) | 		log.Errorf(ctx, "error converting emojis from parent status: %v", err) | ||||||
| 			emojis = make([]apimodel.Emoji, 0) | 		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, | ||||||
|  |  | ||||||
|  | @ -47,6 +47,9 @@ | ||||||
| 				<meter aria-hidden="true" id="poll-{{- $pollOption.PollID -}}-option-{{- increment $index -}}" min="0" max="100" value="{{- $pollOption.VoteShare -}}">{{- $pollOption.VoteShare -}}%</meter> | 				<meter aria-hidden="true" id="poll-{{- $pollOption.PollID -}}-option-{{- increment $index -}}" min="0" max="100" value="{{- $pollOption.VoteShare -}}">{{- $pollOption.VoteShare -}}%</meter> | ||||||
| 				<div class="sr-only">Option {{ increment $index }}: <span lang="{{ .LanguageTag.TagStr }}">{{ emojify .Emojis (noescape $pollOption.Title) -}}</span></div> | 				<div class="sr-only">Option {{ increment $index }}: <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 -}}%</span> | 					<span class="poll-vote-share">{{- $pollOption.VoteShareStr -}}%</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 }} | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue