diff --git a/internal/api/model/poll.go b/internal/api/model/poll.go index a9842e7a9..7eddb66ef 100644 --- a/internal/api/model/poll.go +++ b/internal/api/model/poll.go @@ -17,6 +17,8 @@ package model +import "github.com/superseriousbusiness/gotosocial/internal/language" + // Poll represents a poll attached to a status. // // swagger:model poll @@ -104,3 +106,22 @@ type PollVoteRequest struct { // indices. Can be strings or integers. 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 +} diff --git a/internal/api/model/status.go b/internal/api/model/status.go index 1efae9cfc..5c54bfe96 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -105,8 +105,17 @@ type Status struct { // (used only internally for templating etc). // 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:"-"` + + // Template-ready poll options with vote shares + // calculated as a percentage of total votes. + // Nil for non-web statuses. + // + // swagger:ignore + WebPollOptions []WebPollOption `json:"-"` } /* diff --git a/internal/router/template.go b/internal/router/template.go index d8b5b5edd..804f532bd 100644 --- a/internal/router/template.go +++ b/internal/router/template.go @@ -168,6 +168,10 @@ func acctInstance(acct string) string { return "" } +func increment(i int) int { + return i + 1 +} + func LoadTemplateFunctions(engine *gin.Engine) { engine.SetFuncMap(template.FuncMap{ "escape": escape, @@ -180,5 +184,6 @@ func LoadTemplateFunctions(engine *gin.Engine) { "timestampPrecise": timestampPrecise, "emojify": emojify, "acctInstance": acctInstance, + "increment": increment, }) } diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 8138ee7b4..0668d44bb 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -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 } @@ -1456,10 +1498,17 @@ func (c *Converter) PollToAPIPoll(ctx context.Context, requester *gtsmodel.Accou expiresAt = util.FormatISO8601(poll.ExpiresAt) } - // TODO: emojis used in poll options. - // For now init to empty slice to serialize as `[]`. - // In future inherit from parent status. - emojis = make([]apimodel.Emoji, 0) + // 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) + } + } return &apimodel.Poll{ ID: poll.ID, diff --git a/testrig/testmodels.go b/testrig/testmodels.go index b04e202a7..05eeb48e0 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -1862,8 +1862,8 @@ func NewTestStatuses() map[string]*gtsmodel.Status { }, "local_account_2_status_8": { ID: "01HEN2PRXT0TF4YDRA64FZZRN7", - URI: "http://localhost:8080/users/1happyturtle/statuses/065TKBPE0EJ6X3QDR1AH9DAB8M", - URL: "http://localhost:8080/@1happyturtle/statuses/065TKBPE0EJ6X3QDR1AH9DAB8M", + URI: "http://localhost:8080/users/1happyturtle/statuses/01HEN2PRXT0TF4YDRA64FZZRN7", + URL: "http://localhost:8080/@1happyturtle/statuses/01HEN2PRXT0TF4YDRA64FZZRN7", 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?", AttachmentIDs: nil, diff --git a/web/source/css/status.css b/web/source/css/status.css index 35f2cdd37..49ae63641 100644 --- a/web/source/css/status.css +++ b/web/source/css/status.css @@ -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 { display: flex; background: $toot-info-bg; diff --git a/web/template/poll.tmpl b/web/template/poll.tmpl new file mode 100644 index 000000000..bfc31a9dc --- /dev/null +++ b/web/template/poll.tmpl @@ -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 . +*/ -}} + +{{- /* + Template for rendering a web view of a poll. + To use this template, pass a web view status into it. +*/ -}} + +
+
+ + {{- if .Poll.Expired -}} + Poll closed {{- .Poll.ExpiresAt | timestampPrecise -}} + {{- else if .Poll.ExpiresAt -}} + Poll open until {{- .Poll.ExpiresAt | timestampPrecise -}} + {{- else -}} + Infinite poll (no expiry) + {{- end -}} + + Total votes: {{ .Poll.VotesCount }} +
+ +
diff --git a/web/template/status.tmpl b/web/template/status.tmpl index bf24f6e7c..59725a470 100644 --- a/web/template/status.tmpl +++ b/web/template/status.tmpl @@ -109,6 +109,7 @@ {{end}} {{end}} + {{- if .Poll -}}{{ template "poll.tmpl" . }}{{ end -}}