Skip to content

Commit

Permalink
display poll results and implement voting
Browse files Browse the repository at this point in the history
  • Loading branch information
dimkr committed Oct 16, 2023
1 parent 21c5bfd commit 0063bd5
Show file tree
Hide file tree
Showing 10 changed files with 871 additions and 18 deletions.
14 changes: 11 additions & 3 deletions ap/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ import "time"
type ObjectType string

const (
NoteObject ObjectType = "Note"
PageObject ObjectType = "Page"
ArticleObject ObjectType = "Article"
NoteObject ObjectType = "Note"
PageObject ObjectType = "Page"
ArticleObject ObjectType = "Article"
QuestionObject ObjectType = "Question"
)

type Object struct {
Expand All @@ -41,6 +42,13 @@ type Object struct {
Tag []Mention `json:"tag,omitempty"`
Attachment []Attachment `json:"attachment,omitempty"`
URL string `json:"url,omitempty"`

// polls
VotersCount int64 `json:"votersCount,omitempty"`
OneOf []PollOption `json:"oneOf,omitempty"`
AnyOf []PollOption `json:"anyOf,omitempty"`
EndTime *time.Time `json:"endTime,omitempty"`
Closed *time.Time `json:"closed,omitempty"`
}

func (o *Object) IsPublic() bool {
Expand Down
24 changes: 24 additions & 0 deletions ap/poll_option.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
Copyright 2023 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package ap

type PollOption struct {
Name string `json:"name"`
Replies struct {
TotalItems int64 `json:"totalItems"`
} `json:"replies"`
}
6 changes: 6 additions & 0 deletions front/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ func edit(w text.Writer, r *request) {
return
}

if note.Name != "" {
r.Log.Warn("Cannot edit votes", "vote", note.ID)
w.Status(40, "Cannot edit votes")
return
}

var edits int
if err := r.QueryRow(`select count(*) from outbox where activity->>'object.id' = ? and (activity->>'type' = 'Update' or activity->>'type' = 'Create')`, note.ID).Scan(&edits); err != nil {
r.Log.Warn("Failed to count post edits", "hash", hash, "error", err)
Expand Down
42 changes: 35 additions & 7 deletions front/graph/bars.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,43 @@ package graph
import (
"bytes"
"fmt"
"unicode/utf8"
)

func Bars(keys []string, values []int64) string {
flip := false
var keyWidth int
for _, key := range keys {
l := len(key)
if l > keyWidth {
keyWidth = l
for i, key := range keys {
w := utf8.RuneCountInString(key)
/*
if keys have different lengths, put them on the right: the graph is
misaligned if labels contain emojis but viewed through a problematic
terminal emulator or if emoji fonts are old or missing; we must put
labels on the right to ensure that everything else is aligned
*/
if i > 0 && w > 0 && w != keyWidth {
flip = true
break
}
if w > keyWidth {
keyWidth = w
}
}

valueStrings := make([]string, len(values))

var valueWidth int
var max int64
for _, v := range values {
for i, v := range values {
if v > max {
max = v
}
s := fmt.Sprintf("%d", v)
valueStrings[i] = s
l := len(s)
if l > valueWidth {
valueWidth = l
}
}

unit := float64(max) / 8
Expand All @@ -48,7 +69,9 @@ func Bars(keys []string, values []int64) string {

var bar [8]rune
for j, v := 0, float64(values[i]); j < 8; j, v = j+1, v-unit {
if v >= unit {
if unit == 0 {
bar[j] = ' '
} else if v >= unit {
bar[j] = '█'
} else if v >= unit*7/8 {
bar[j] = '▉'
Expand All @@ -70,7 +93,12 @@ func Bars(keys []string, values []int64) string {
bar[j] = ' '
}
}
fmt.Fprintf(&w, "%-*s %8s %d\n", keyWidth, keys[i], string(bar[:]), values[i])

if flip {
fmt.Fprintf(&w, "%-*s %8s %s\n", valueWidth, valueStrings[i], string(bar[:]), keys[i])
} else {
fmt.Fprintf(&w, "%-*s %8s %d\n", keyWidth, keys[i], string(bar[:]), values[i])
}
}

return w.String()
Expand Down
19 changes: 19 additions & 0 deletions front/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,25 @@ func post(w text.Writer, r *request, inReplyTo *ap.Object, to ap.Audience, cc ap

if inReplyTo != nil {
note.InReplyTo = inReplyTo.ID

if inReplyTo.Type == ap.QuestionObject {
options := inReplyTo.OneOf
if len(options) == 0 {
options = inReplyTo.AnyOf
}

for _, option := range options {
if option.Name == note.Content {
if inReplyTo.Closed != nil || inReplyTo.EndTime != nil && time.Now().After(*inReplyTo.EndTime) {
w.Status(40, "Cannot vote in a closed poll")
return
}

note.Content = ""
note.Name = option.Name
}
}
}
}

if err := outbox.Create(r.Context, r.Log, r.DB, &note, r.User); err != nil {
Expand Down
19 changes: 16 additions & 3 deletions front/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,10 @@ func (r *request) PrintNote(w text.Writer, note *ap.Object, author *ap.Actor, gr
}

noteBody := note.Content
if note.Name != "" { // Page has a title
if note.Name != "" && note.Content != "" { // Page has a title
noteBody = fmt.Sprintf("%s<br>%s", note.Name, note.Content)
} else if note.Name != "" && note.Content == "" { // this Note is a poll vote
noteBody = note.Name
}

contentLines, inlineLinks := getTextAndLinks(noteBody, maxRunes, maxLines)
Expand Down Expand Up @@ -316,10 +318,21 @@ func (r *request) PrintNote(w text.Writer, note *ap.Object, author *ap.Actor, gr
return true
})

if r.User != nil && note.AttributedTo == r.User.ID {
if r.User != nil && note.AttributedTo == r.User.ID && note.Name == "" { // poll votes cannot be edited
w.Link(fmt.Sprintf("/users/edit/%x", sha256.Sum256([]byte(note.ID))), "🩹 Edit")
}
if r.User != nil && note.AttributedTo == r.User.ID {
w.Link(fmt.Sprintf("/users/delete/%x", sha256.Sum256([]byte(note.ID))), "💣 Delete")
}
if r.User != nil && note.Type == ap.QuestionObject && note.Closed == nil && (note.EndTime == nil || time.Now().Before(*note.EndTime)) {
options := note.OneOf
if len(options) == 0 {
options = note.AnyOf
}
for _, option := range options {
w.Linkf(fmt.Sprintf("/users/reply/%x?%s", sha256.Sum256([]byte(note.ID)), url.PathEscape(option.Name)), "📮 Vote %s", option.Name)
}
}
if r.User != nil {
w.Link(fmt.Sprintf("/users/reply/%x", sha256.Sum256([]byte(note.ID))), "💬 Reply")
}
Expand All @@ -335,7 +348,7 @@ func (r *request) PrintNotes(w text.Writer, rows data.OrderedMap[string, noteMet
return true
}

if note.Type != ap.NoteObject && note.Type != ap.PageObject && note.Type != ap.ArticleObject {
if note.Type != ap.NoteObject && note.Type != ap.PageObject && note.Type != ap.ArticleObject && note.Type != ap.QuestionObject {
r.Log.Warn("Post type is unsupported", "type", note.Type)
return true
}
Expand Down
28 changes: 28 additions & 0 deletions front/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"fmt"
"github.com/dimkr/tootik/ap"
"github.com/dimkr/tootik/data"
"github.com/dimkr/tootik/front/graph"
"github.com/dimkr/tootik/front/text"
"path/filepath"
)
Expand Down Expand Up @@ -122,6 +123,33 @@ func view(w text.Writer, r *request) {
r.PrintNote(w, &note, &author, nil, false, false, true, false)
}

if note.Type == ap.QuestionObject && note.VotersCount > 0 && offset == 0 {
options := note.OneOf
if len(options) == 0 {
options = note.AnyOf
}

if len(options) > 0 {
w.Empty()

if note.VotersCount == 1 {
w.Subtitle("📊 Results (one voter)")
} else {
w.Subtitlef("📊 Results (%d voters)", note.VotersCount)
}

labels := make([]string, 0, len(options))
votes := make([]int64, 0, len(options))

for _, option := range options {
labels = append(labels, option.Name)
votes = append(votes, option.Replies.TotalItems)
}

w.Raw("Results graph", graph.Bars(labels, votes))
}
}

if count > 0 && offset >= repliesPerPage {
w.Empty()
w.Subtitlef("💬 Replies to %s (%d-%d)", author.PreferredUsername, offset, offset+repliesPerPage)
Expand Down
30 changes: 25 additions & 5 deletions inbox/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,22 +303,42 @@ func processActivity(ctx context.Context, log *slog.Logger, sender *ap.Actor, re
return fmt.Errorf("%s cannot update posts by %s", sender.ID, post.AttributedTo)
}

var lastUpdate sql.NullInt64
if err := db.QueryRowContext(ctx, `select max(inserted, updated) from notes where id = ? and author = ?`, post.ID, post.AttributedTo).Scan(&lastUpdate); err != nil && errors.Is(err, sql.ErrNoRows) {
var oldPostString string
var lastUpdate int64
if err := db.QueryRowContext(ctx, `select max(inserted, updated), object from notes where id = ? and author = ?`, post.ID, post.AttributedTo).Scan(&lastUpdate, &oldPostString); err != nil && errors.Is(err, sql.ErrNoRows) {
log.Debug("Received Update for non-existing post")
return processCreateActivity(ctx, log, sender, req, rawActivity, post, db, resolver, from)
} else if err != nil {
return fmt.Errorf("Failed to get last update time for %s: %w", post.ID, err)
}

if !lastUpdate.Valid || post.Updated == nil || lastUpdate.Int64 >= post.Updated.UnixNano() {
var oldPost ap.Object
if err := json.Unmarshal([]byte(oldPostString), &oldPost); err != nil {
return fmt.Errorf("Failed to unmarshal old note: %w", err)
}

var body []byte
var err error
if (post.Type == ap.QuestionObject && post.Updated != nil && lastUpdate >= post.Updated.Unix()) || (post.Type != ap.QuestionObject && (post.Updated == nil || lastUpdate >= post.Updated.Unix())) {
log.Debug("Received old update request for new post")
return nil
} else if post.Type == ap.QuestionObject && oldPost.Closed != nil {
log.Debug("Received update request for closed poll")
return nil
} else if post.Type == ap.QuestionObject && post.Updated == nil {
oldPost.VotersCount = post.VotersCount
oldPost.OneOf = post.OneOf
oldPost.AnyOf = post.AnyOf
oldPost.EndTime = post.EndTime
oldPost.Closed = post.Closed

body, err = json.Marshal(oldPost)
} else {
body, err = json.Marshal(post)
}

body, err := json.Marshal(post)
if err != nil {
return fmt.Errorf("Failed to update post %s: %w", post.ID, err)
return fmt.Errorf("Failed to marshal updated post %s: %w", post.ID, err)
}

tx, err := db.BeginTx(ctx, nil)
Expand Down
Loading

0 comments on commit 0063bd5

Please sign in to comment.