[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
|
@ -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;
|
||||||
|
|
|
@ -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…
Reference in New Issue