diff --git a/pkg/config/config.go b/pkg/config/config.go index e45fd62..cf32fbb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -96,11 +96,26 @@ const ( // rapidly it does not increment the view counter more. ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d" ThreadViewDebounceCooldown = 1 * time.Hour +) +// Poll settings +var ( // Max number of responses to accept for a poll (how many form // values the app will read in). NOTE: also enforced in frontend // UX in new_post.html, update there if you change this. PollMaxAnswers = 100 + + // Poll color CSS classes (Bulma). Plugged in to templates like: + // + // Values will wrap around for long polls. + PollProgressBarClasses = []string{ + "progress is-success", + "progress is-link", + "progress is-warning", + "progress is-danger", + "progress is-primary", + "progress is-info", + } ) // Variables set by main.go to make them readily available. diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go index 6ce99a4..e4d6aa8 100644 --- a/pkg/controller/forum/new_post.go +++ b/pkg/controller/forum/new_post.go @@ -47,9 +47,10 @@ func NewPost() http.HandlerFunc { isOriginalComment bool // Polls - pollOptions = []string{} - pollExpires = 3 - isPoll bool + pollOptions = []string{} + pollExpires = 3 + pollMultipleChoice = r.FormValue("poll_multiple_choice") == "true" + isPoll bool // Attached photo object. commentPhoto *models.CommentPhoto @@ -179,13 +180,27 @@ func NewPost() http.HandlerFunc { if r.Method == http.MethodPost { // Polls: parse form parameters into a neat list of answers. pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires")) + var distinctPollChoices = map[string]interface{}{} for i := 0; i < config.PollMaxAnswers; i++ { if value := r.FormValue(fmt.Sprintf("answer%d", i)); value != "" { pollOptions = append(pollOptions, value) isPoll = len(pollOptions) >= 2 + + // Make sure every option is distinct! + if _, ok := distinctPollChoices[value]; ok { + session.FlashError(w, r, "Your poll options must all be unique! Duplicate option '%s' seen in your post!", value) + intent = "preview" // do not continue to submit + } + distinctPollChoices[value] = nil } } + // If only one poll option, warn about it. + if len(pollOptions) == 1 { + session.FlashError(w, r, "Your poll should have at least two choices.") + intent = "preview" // do not continue to submit + } + // Is a photo coming along? if forum.PermitPhotos { // Removing or replacing? @@ -375,6 +390,7 @@ func NewPost() http.HandlerFunc { if isPoll { log.Info("It's a Poll! Options: %+v", pollOptions) poll := models.CreatePoll(pollOptions, pollExpires) + poll.MultipleChoice = pollMultipleChoice if err := poll.Save(); err != nil { session.FlashError(w, r, "Error creating poll: %s", err) } diff --git a/pkg/controller/poll/vote.go b/pkg/controller/poll/vote.go new file mode 100644 index 0000000..f7dffec --- /dev/null +++ b/pkg/controller/poll/vote.go @@ -0,0 +1,91 @@ +package poll + +import ( + "fmt" + "net/http" + "strconv" + + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// Vote controller for polls. +func Vote() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + // Form parameters + pollID uint64 + fromThreadID uint64 + answers = r.Form["answer"] // a slice in case of MultipleChoice + nextURL string + ) + + // Parse integer params. + if value, err := strconv.Atoi(r.FormValue("poll_id")); err != nil { + session.FlashError(w, r, "Invalid poll ID") + templates.Redirect(w, "/") + return + } else { + pollID = uint64(value) + } + + // Currently polls only exist in forum threads, require the thread ID. + if value, err := strconv.Atoi(r.FormValue("from_thread_id")); err != nil { + session.FlashError(w, r, "Invalid thread ID") + templates.Redirect(w, "/") + return + } else { + fromThreadID = uint64(value) + nextURL = fmt.Sprintf("/forum/thread/%d", fromThreadID) + } + + // POST request only. + if r.Method != http.MethodPost { + session.FlashError(w, r, "POST requests only.") + templates.Redirect(w, nextURL) + return + } + + // An answer is required. + if len(answers) == 0 || len(answers) == 1 && answers[0] == "" { + session.FlashError(w, r, "An answer to this poll is required for voting.") + templates.Redirect(w, nextURL) + return + } + + // Get the current user. + user, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser") + templates.Redirect(w, nextURL) + return + } + + // Look up the poll. + poll, err := models.GetPoll(pollID) + if err != nil { + session.FlashError(w, r, "Poll not found.") + templates.Redirect(w, nextURL) + return + } + + // Is it accepting responses? + result := poll.Result(user) + if !result.AcceptingVotes { + session.FlashError(w, r, "This poll is not accepting your vote at this time.") + templates.Redirect(w, nextURL) + return + } + + // Cast the vote! + if err := poll.CastVote(user, answers); err != nil { + session.FlashError(w, r, "Couldn't cast the vote: %s", err) + templates.Redirect(w, nextURL) + return + } + + session.Flash(w, r, "Your vote has been recorded!") + templates.Redirect(w, nextURL) + }) +} diff --git a/pkg/models/forum_recent.go b/pkg/models/forum_recent.go index a68da23..38febb4 100644 --- a/pkg/models/forum_recent.go +++ b/pkg/models/forum_recent.go @@ -35,7 +35,7 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([] // Hide explicit forum if user hasn't opted into it. if !user.Explicit && !user.IsAdmin { - wheres = append(wheres, "explicit = false") + wheres = append(wheres, "forums.explicit = false") } // Get the page of recent forum comment IDs of all time. diff --git a/pkg/models/models.go b/pkg/models/models.go index b8cfce7..68647dd 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -25,4 +25,5 @@ func AutoMigrate() { DB.AutoMigrate(&Subscription{}) DB.AutoMigrate(&CommentPhoto{}) DB.AutoMigrate(&Poll{}) + DB.AutoMigrate(&PollVote{}) } diff --git a/pkg/models/poll.go b/pkg/models/poll.go index 3730a8d..66eb715 100644 --- a/pkg/models/poll.go +++ b/pkg/models/poll.go @@ -1,8 +1,11 @@ package models import ( + "fmt" "strings" "time" + + "code.nonshy.com/nonshy/website/pkg/config" ) // Poll table for user surveys posted in the forums. @@ -27,6 +30,7 @@ type Poll struct { func CreatePoll(choices []string, expires int) *Poll { return &Poll{ Choices: strings.Join(choices, "\n"), + Expires: expires > 0, ExpiresAt: time.Now().Add(time.Duration(expires) * 24 * time.Hour), } } @@ -43,8 +47,89 @@ func (p *Poll) Options() []string { return strings.Split(p.Choices, "\n") } +// IsExpired returns if the poll has ended. +func (p *Poll) IsExpired() bool { + return p.Expires && time.Now().After(p.ExpiresAt) +} + +// InputType returns "radio" or "checkbox" for multiple choice polls. +func (p *Poll) InputType() string { + if p.MultipleChoice { + return "checkbox" + } + return "radio" +} + +// Result returns metadata about a poll's status and results, for frontend assist. +func (p *Poll) Result(currentUser *User) PollResult { + var ( + result = PollResult{ + AcceptingVotes: true, + CurrentUserVote: []string{}, + Results: map[string]int{}, + ResultsPercent: map[string]float64{}, + ResultsClass: map[string]string{}, + } + votes = p.GetAllVotes() + distinctAnswers int + ) + + // Populate the CSS classes. + for i, answer := range p.Options() { + result.ResultsClass[answer] = config.PollProgressBarClasses[i%len(config.PollProgressBarClasses)] + } + + result.TotalVotes = len(votes) + for _, res := range votes { + if res.UserID == currentUser.ID { + result.CurrentUserVote = append(result.CurrentUserVote, res.Answer) + result.AcceptingVotes = false + } + + if _, ok := result.Results[res.Answer]; !ok { + distinctAnswers++ + result.Results[res.Answer] = 0 + result.ResultsPercent[res.Answer] = 0 + } + result.Results[res.Answer]++ + } + + // Compute the percent splits. + if result.TotalVotes > 0 { + for answer, count := range result.Results { + result.ResultsPercent[answer] = float64(count) / float64(result.TotalVotes) + } + } + + // Expired polls don't accept answers. + if p.IsExpired() { + result.AcceptingVotes = false + } + + return result +} + // Save Poll. func (p *Poll) Save() error { result := DB.Save(p) return result.Error } + +// PollResult holds metadata about the poll result for frontend display. +type PollResult struct { + AcceptingVotes bool // user voted or it expired + CurrentUserVote []string // current user's selection, if any + Results map[string]int // answers and their % + ResultsPercent map[string]float64 + ResultsClass map[string]string // progress bar classes + TotalVotes int +} + +func (pr PollResult) GetPercent(answer string) string { + value := pr.ResultsPercent[answer] + return fmt.Sprintf("%.1f", value*100) +} + +func (pr PollResult) GetClass(answer string) string { + return pr.ResultsClass[answer] +} diff --git a/pkg/models/poll_votes.go b/pkg/models/poll_votes.go index 04d20d9..1a0afa4 100644 --- a/pkg/models/poll_votes.go +++ b/pkg/models/poll_votes.go @@ -1,6 +1,11 @@ package models -import "time" +import ( + "errors" + "time" + + "code.nonshy.com/nonshy/website/pkg/log" +) // PollVote table records answers to polls. type PollVote struct { @@ -12,3 +17,52 @@ type PollVote struct { CreatedAt time.Time UpdatedAt time.Time } + +// CastVote on a poll. Multiple answers OK for multiple choice polls. +func (p *Poll) CastVote(user *User, answers []string) error { + if len(answers) > 1 && !p.MultipleChoice { + return errors.New("multiple answers not accepted for this poll") + } + + // If this user has already voted, remove their vote. + result := DB.Where( + "poll_id = ? AND user_id = ?", + p.ID, user.ID, + ).Delete(&PollVote{}) + if result.Error != nil { + return result.Error + } + + // Insert their votes. + var err error + for _, answer := range answers { + vote := &PollVote{ + PollID: p.ID, + UserID: user.ID, + Answer: answer, + } + err = vote.Save() + } + + return err +} + +// GetAllVotes for a poll. +func (p *Poll) GetAllVotes() []*PollVote { + var pv = []*PollVote{} + + result := DB.Where( + "poll_id = ?", p.ID, + ).Find(&pv) + if result.Error != nil { + log.Error("Poll(%d).GetAllVotes(): %s", p.ID, result.Error) + } + + return pv +} + +// Save Poll. +func (v *PollVote) Save() error { + result := DB.Save(v) + return result.Error +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 8c9abdd..50998e7 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -15,6 +15,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/controller/inbox" "code.nonshy.com/nonshy/website/pkg/controller/index" "code.nonshy.com/nonshy/website/pkg/controller/photo" + "code.nonshy.com/nonshy/website/pkg/controller/poll" "code.nonshy.com/nonshy/website/pkg/middleware" ) @@ -67,6 +68,7 @@ func New() http.Handler { mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread())) mux.Handle("/forum/newest", middleware.CertRequired(forum.Newest())) mux.Handle("/f/", middleware.CertRequired(forum.Forum())) + mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote())) // Admin endpoints. mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) diff --git a/pkg/utility/time.go b/pkg/utility/time.go index bbfea7c..556c7df 100644 --- a/pkg/utility/time.go +++ b/pkg/utility/time.go @@ -8,6 +8,11 @@ import ( // FormatDurationCoarse returns a pretty printed duration with coarse granularity. func FormatDurationCoarse(duration time.Duration) string { + // Negative durations (e.g. future dates) should work too. + if duration < 0 { + duration *= -1 + } + var result = func(text string, v int64) string { if v == 1 { text = strings.TrimSuffix(text, "s") diff --git a/web/templates/forum/board_index.html b/web/templates/forum/board_index.html index 4275d55..9cef992 100644 --- a/web/templates/forum/board_index.html +++ b/web/templates/forum/board_index.html @@ -105,6 +105,13 @@ {{end}} + {{if .PollID}} + + + Poll + + {{end}} + {{if .Explicit}} diff --git a/web/templates/forum/new_post.html b/web/templates/forum/new_post.html index 8ba872b..1c268cd 100644 --- a/web/templates/forum/new_post.html +++ b/web/templates/forum/new_post.html @@ -81,10 +81,20 @@

- {{if not .EditCommentID}} + {{if and (not .EditCommentID) (not .Thread)}}
+ +
+ +
+
-
+
- Poll expires: + Poll expires:
@@ -355,7 +365,7 @@ delimiters: ['[[', ']]'], data() { return { - pollVisible: true, + pollVisible: pollOptions.length > 0, answers: [ {value: ""}, {value: ""}, diff --git a/web/templates/forum/newest.html b/web/templates/forum/newest.html index a88021b..fec9ffd 100644 --- a/web/templates/forum/newest.html +++ b/web/templates/forum/newest.html @@ -81,6 +81,10 @@ {{end}} + {{if .Thread.PollID}} + + {{end}} + {{if .Thread.Explicit}} {{end}} diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html index 897382f..04e9e7d 100644 --- a/web/templates/forum/thread.html +++ b/web/templates/forum/thread.html @@ -153,27 +153,71 @@ {{if and (eq $i 0) $Root.Thread.PollID}}

Poll

+ + {{$Poll := $Root.Thread.Poll}} + {{$PollResult := $Poll.Result $Root.CurrentUser}} +
{{InputCSRF}} + + {{if $PollResult.AcceptingVotes}} + {{range $Poll.Options}} +
+ +
+ {{end}} - {{range $Root.Thread.Poll.Options}} -
- -
+ {{if $Poll.MultipleChoice}} +
+ Multiple choice: select all the answers you want before casting your vote! +
+ {{end}} + +
+ {{if and ($Poll.Expires) (not $Poll.IsExpired)}} + Poll expires in about {{SincePrettyCoarse $Root.Thread.Poll.ExpiresAt}}. + Vote or wait to see the responses. + {{else}} + Poll doesn't expire. Vote to see the responses. + {{end}} +
+ + + {{else}} + {{range $Poll.Options}} +
+
+ {{.}} +
+
+ {{$PollResult.GetPercent .}}% +
+
+ {{$PollResult.GetPercent .}}% +
+
+ {{end}} + + + {{$PollResult.TotalVotes}} vote{{Pluralize $PollResult.TotalVotes}}. + {{if $Poll.IsExpired}} + Poll ended {{SincePrettyCoarse $Root.Thread.Poll.ExpiresAt}} ago. + {{end}} + {{end}} - +
{{end}}