mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 15:22:26 -05:00 
			
		
		
		
	[feature] Poll web view (#2377)
* [feature] Render polls nicely on the web view * use figure for poll, other small tweaks * reverse share + count (lines up better) * poll options list entries * fix up some remaining things
This commit is contained in:
		
					parent
					
						
							
								cfefbc08d8
							
						
					
				
			
			
				commit
				
					
						8c2d94c168
					
				
			
		
					 8 changed files with 207 additions and 7 deletions
				
			
		|  | @ -17,6 +17,8 @@ | ||||||
| 
 | 
 | ||||||
| package model | package model | ||||||
| 
 | 
 | ||||||
|  | import "github.com/superseriousbusiness/gotosocial/internal/language" | ||||||
|  | 
 | ||||||
| // Poll represents a poll attached to a status. | // Poll represents a poll attached to a status. | ||||||
| // | // | ||||||
| // swagger:model poll | // swagger:model poll | ||||||
|  | @ -104,3 +106,22 @@ type PollVoteRequest struct { | ||||||
| 	// indices. Can be strings or integers. | 	// indices. Can be strings or integers. | ||||||
| 	ChoicesI []interface{} `json:"choices"` | 	ChoicesI []interface{} `json:"choices"` | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // WebPollOption models a template-ready poll option entry. | ||||||
|  | // | ||||||
|  | // swagger:ignore | ||||||
|  | type WebPollOption struct { | ||||||
|  | 	PollOption | ||||||
|  | 
 | ||||||
|  | 	// Emojis contained on parent poll. | ||||||
|  | 	Emojis []Emoji | ||||||
|  | 
 | ||||||
|  | 	// LanguageTag of parent status. | ||||||
|  | 	LanguageTag *language.Language | ||||||
|  | 
 | ||||||
|  | 	// Share of total votes as a percentage. | ||||||
|  | 	VoteShare float32 | ||||||
|  | 
 | ||||||
|  | 	// String-formatted version of VoteShare. | ||||||
|  | 	VoteShareStr string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -105,8 +105,17 @@ type Status struct { | ||||||
| 	// (used only internally for templating etc). | 	// (used only internally for templating etc). | ||||||
| 
 | 
 | ||||||
| 	// Template-ready language tag + string, based | 	// Template-ready language tag + string, based | ||||||
| 	// on *status.Language. Nil for non-web statuses | 	// on *status.Language. Nil for non-web statuses. | ||||||
|  | 	// | ||||||
|  | 	// swagger:ignore | ||||||
| 	LanguageTag *language.Language `json:"-"` | 	LanguageTag *language.Language `json:"-"` | ||||||
|  | 
 | ||||||
|  | 	// Template-ready poll options with vote shares | ||||||
|  | 	// calculated as a percentage of total votes. | ||||||
|  | 	// Nil for non-web statuses. | ||||||
|  | 	// | ||||||
|  | 	// swagger:ignore | ||||||
|  | 	WebPollOptions []WebPollOption `json:"-"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /* | /* | ||||||
|  |  | ||||||
|  | @ -168,6 +168,10 @@ func acctInstance(acct string) string { | ||||||
| 	return "" | 	return "" | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func increment(i int) int { | ||||||
|  | 	return i + 1 | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func LoadTemplateFunctions(engine *gin.Engine) { | func LoadTemplateFunctions(engine *gin.Engine) { | ||||||
| 	engine.SetFuncMap(template.FuncMap{ | 	engine.SetFuncMap(template.FuncMap{ | ||||||
| 		"escape":           escape, | 		"escape":           escape, | ||||||
|  | @ -180,5 +184,6 @@ func LoadTemplateFunctions(engine *gin.Engine) { | ||||||
| 		"timestampPrecise": timestampPrecise, | 		"timestampPrecise": timestampPrecise, | ||||||
| 		"emojify":          emojify, | 		"emojify":          emojify, | ||||||
| 		"acctInstance":     acctInstance, | 		"acctInstance":     acctInstance, | ||||||
|  | 		"increment":        increment, | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -678,6 +678,48 @@ func (c *Converter) StatusToWebStatus( | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	if poll := webStatus.Poll; poll != nil { | ||||||
|  | 		// Calculate vote share of each poll option and | ||||||
|  | 		// format them for easier template consumption. | ||||||
|  | 		totalVotes := poll.VotesCount | ||||||
|  | 
 | ||||||
|  | 		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 | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// Format to two decimal points and ditch any | ||||||
|  | 			// trailing zeroes. | ||||||
|  | 			// | ||||||
|  | 			// We want to be precise enough that eg., "1.54%" | ||||||
|  | 			// is distinct from "1.68%" in polls with loads | ||||||
|  | 			// of votes. | ||||||
|  | 			// | ||||||
|  | 			// However, if we've got eg., a two-option poll | ||||||
|  | 			// in which each option has half the votes, then | ||||||
|  | 			// "50%" looks better than "50.00%". | ||||||
|  | 			// | ||||||
|  | 			// By the same token, it's pointless to show | ||||||
|  | 			// "0.00%" or "100.00%". | ||||||
|  | 			voteShareStr := fmt.Sprintf("%.2f", voteShare) | ||||||
|  | 			voteShareStr = strings.TrimSuffix(voteShareStr, ".00") | ||||||
|  | 
 | ||||||
|  | 			webPollOption := apimodel.WebPollOption{ | ||||||
|  | 				PollOption:   option, | ||||||
|  | 				Emojis:       webStatus.Emojis, | ||||||
|  | 				LanguageTag:  webStatus.LanguageTag, | ||||||
|  | 				VoteShare:    voteShare, | ||||||
|  | 				VoteShareStr: voteShareStr, | ||||||
|  | 			} | ||||||
|  | 			webPollOptions[i] = webPollOption | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		webStatus.WebPollOptions = webPollOptions | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	return webStatus, nil | 	return webStatus, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -1456,10 +1498,17 @@ func (c *Converter) PollToAPIPoll(ctx context.Context, requester *gtsmodel.Accou | ||||||
| 		expiresAt = util.FormatISO8601(poll.ExpiresAt) | 		expiresAt = util.FormatISO8601(poll.ExpiresAt) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// TODO: emojis used in poll options. | 	// Try to inherit emojis | ||||||
| 	// For now init to empty slice to serialize as `[]`. | 	// from parent status. | ||||||
| 	// In future inherit from parent status. | 	if pStatus := poll.Status; pStatus != nil { | ||||||
| 	emojis = make([]apimodel.Emoji, 0) | 		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) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	return &apimodel.Poll{ | 	return &apimodel.Poll{ | ||||||
| 		ID:          poll.ID, | 		ID:          poll.ID, | ||||||
|  |  | ||||||
|  | @ -1862,8 +1862,8 @@ func NewTestStatuses() map[string]*gtsmodel.Status { | ||||||
| 		}, | 		}, | ||||||
| 		"local_account_2_status_8": { | 		"local_account_2_status_8": { | ||||||
| 			ID:                       "01HEN2PRXT0TF4YDRA64FZZRN7", | 			ID:                       "01HEN2PRXT0TF4YDRA64FZZRN7", | ||||||
| 			URI:                      "http://localhost:8080/users/1happyturtle/statuses/065TKBPE0EJ6X3QDR1AH9DAB8M", | 			URI:                      "http://localhost:8080/users/1happyturtle/statuses/01HEN2PRXT0TF4YDRA64FZZRN7", | ||||||
| 			URL:                      "http://localhost:8080/@1happyturtle/statuses/065TKBPE0EJ6X3QDR1AH9DAB8M", | 			URL:                      "http://localhost:8080/@1happyturtle/statuses/01HEN2PRXT0TF4YDRA64FZZRN7", | ||||||
| 			Content:                  "hey everyone i got stuck in a shed. any ideas for how to get out?", | 			Content:                  "hey everyone i got stuck in a shed. any ideas for how to get out?", | ||||||
| 			Text:                     "hey everyone i got stuck in a shed. any ideas for how to get out?", | 			Text:                     "hey everyone i got stuck in a shed. any ideas for how to get out?", | ||||||
| 			AttachmentIDs:            nil, | 			AttachmentIDs:            nil, | ||||||
|  |  | ||||||
|  | @ -391,6 +391,64 @@ main { | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	.poll { | ||||||
|  | 		background-color: $gray2; | ||||||
|  | 		z-index: 2; | ||||||
|  | 		 | ||||||
|  | 		display: flex; | ||||||
|  | 		flex-direction: column; | ||||||
|  | 		border-radius: $br; | ||||||
|  | 		padding: 0.5rem; | ||||||
|  | 		margin: 0; | ||||||
|  | 		gap: 1rem; | ||||||
|  | 
 | ||||||
|  | 		.poll-options { | ||||||
|  | 			margin: 0; | ||||||
|  | 			padding: 0; | ||||||
|  | 			display: flex; | ||||||
|  | 			flex-direction: column; | ||||||
|  | 			gap: 1rem; | ||||||
|  | 
 | ||||||
|  | 			.poll-option { | ||||||
|  | 				display: flex; | ||||||
|  | 				flex-direction: column; | ||||||
|  | 				gap: 0.1rem; | ||||||
|  | 	 | ||||||
|  | 				label { | ||||||
|  | 					cursor: default; | ||||||
|  | 				} | ||||||
|  | 	 | ||||||
|  | 				meter { | ||||||
|  | 					width: 100%; | ||||||
|  | 				} | ||||||
|  | 	 | ||||||
|  | 				.poll-vote-summary { | ||||||
|  | 					display: flex; | ||||||
|  | 					flex-wrap: wrap; | ||||||
|  | 					justify-content: space-between; | ||||||
|  | 					white-space: nowrap; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		.poll-info { | ||||||
|  | 			background-color: $gray4; | ||||||
|  | 			display: flex; | ||||||
|  | 			flex-wrap: wrap; | ||||||
|  | 			justify-content: space-between; | ||||||
|  | 			border-radius: $br-inner; | ||||||
|  | 			padding: 0.25rem; | ||||||
|  | 			gap: 0.25rem; | ||||||
|  | 
 | ||||||
|  | 			span { | ||||||
|  | 				justify-self: center; | ||||||
|  | 				white-space: nowrap; | ||||||
|  | 				overflow: hidden; | ||||||
|  | 				text-overflow: ellipsis; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	.info { | 	.info { | ||||||
| 		display: flex; | 		display: flex; | ||||||
| 		background: $toot-info-bg; | 		background: $toot-info-bg; | ||||||
|  |  | ||||||
							
								
								
									
										57
									
								
								web/template/poll.tmpl
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								web/template/poll.tmpl
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | ||||||
|  | {{- /* | ||||||
|  | // GoToSocial | ||||||
|  | // Copyright (C) GoToSocial Authors admin@gotosocial.org | ||||||
|  | // SPDX-License-Identifier: AGPL-3.0-or-later | ||||||
|  | // | ||||||
|  | // This program is free software: you can redistribute it and/or modify | ||||||
|  | // it under the terms of the GNU Affero General Public License as published by | ||||||
|  | // the Free Software Foundation, either version 3 of the License, or | ||||||
|  | // (at your option) any later version. | ||||||
|  | // | ||||||
|  | // This program is distributed in the hope that it will be useful, | ||||||
|  | // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | // GNU Affero General Public License for more details. | ||||||
|  | // | ||||||
|  | // You should have received a copy of the GNU Affero General Public License | ||||||
|  | // along with this program.  If not, see <http://www.gnu.org/licenses/>. | ||||||
|  | */ -}} | ||||||
|  | 
 | ||||||
|  | {{- /* | ||||||
|  | 		Template for rendering a web view of a poll. | ||||||
|  | 		To use this template, pass a web view status into it. | ||||||
|  | */ -}} | ||||||
|  | 
 | ||||||
|  | 	<figure class="poll"> | ||||||
|  | 		<figcaption class="poll-info"> | ||||||
|  | 			<span class="poll-expiry"> | ||||||
|  | 				{{- if .Poll.Expired -}} | ||||||
|  | 					Poll closed {{- .Poll.ExpiresAt | timestampPrecise -}} | ||||||
|  | 				{{- else if .Poll.ExpiresAt -}} | ||||||
|  | 					Poll open until {{- .Poll.ExpiresAt | timestampPrecise -}} | ||||||
|  | 				{{- else -}} | ||||||
|  | 					Infinite poll (no expiry) | ||||||
|  | 				{{- end -}} | ||||||
|  | 			</span> | ||||||
|  | 			<span class="total-votes">Total votes: {{ .Poll.VotesCount }}</span> | ||||||
|  | 		</figcaption> | ||||||
|  | 		<ul class="poll-options"> | ||||||
|  | 		{{- range $index, $pollOption := .WebPollOptions }} | ||||||
|  | 			<li class="poll-option"> | ||||||
|  | 				<label aria-hidden="true" for="option-{{- increment $index -}}" lang="{{- .LanguageTag.TagStr -}}">{{- emojify .Emojis (noescape $pollOption.Title) -}}</label> | ||||||
|  | 				<meter aria-hidden="true" id="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="poll-vote-summary"> | ||||||
|  | 					<span class="poll-vote-share">{{- $pollOption.VoteShareStr -}}%</span> | ||||||
|  | 					<span class="poll-vote-count"> | ||||||
|  | 						{{- if eq $pollOption.VotesCount 1 -}} | ||||||
|  | 							{{- $pollOption.VotesCount }} vote | ||||||
|  | 						{{- else -}} | ||||||
|  | 							{{- $pollOption.VotesCount }} votes | ||||||
|  | 						{{- end -}} | ||||||
|  | 					</span> | ||||||
|  | 				</div> | ||||||
|  | 			</li> | ||||||
|  | 		{{- end }} | ||||||
|  | 		</ul> | ||||||
|  | 	</figure> | ||||||
|  | @ -109,6 +109,7 @@ | ||||||
| 		{{end}} | 		{{end}} | ||||||
| 	</div> | 	</div> | ||||||
| 	{{end}} | 	{{end}} | ||||||
|  | 	{{- if .Poll -}}{{ template "poll.tmpl" . }}{{ end -}} | ||||||
| </section> | </section> | ||||||
| <aside class="info"> | <aside class="info"> | ||||||
| 	<time datetime="{{.CreatedAt}}">{{.CreatedAt | timestampPrecise}}</time> | 	<time datetime="{{.CreatedAt}}">{{.CreatedAt | timestampPrecise}}</time> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue