Add poll support

Currenlty only voting is possible.
This commit is contained in:
r 2020-02-09 13:42:16 +00:00
parent a68a09a83e
commit cfec7879e3
9 changed files with 182 additions and 39 deletions

38
mastodon/poll.go Normal file
View File

@ -0,0 +1,38 @@
package mastodon
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
)
type Poll struct {
ID string `json:"id"`
ExpiresAt time.Time `json:"expires_at"`
Expired bool `json:"expired"`
Multiple bool `json:"multiple"`
VotesCount int64 `json:"votes_count"`
Voted bool `json:"voted"`
Emojis []Emoji `json:"emojis"`
Options []PollOption `json:"options"`
}
// Poll hold information for a mastodon poll option.
type PollOption struct {
Title string `json:"title"`
VotesCount int64 `json:"votes_count"`
}
// Vote submits a vote with given choices to the poll specified by id.
func (c *Client) Vote(ctx context.Context, id string, choices []string) (*Poll, error) {
var poll Poll
params := make(url.Values)
params["choices[]"] = choices
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/polls/%s/votes", id), params, &poll, nil)
if err != nil {
return nil, err
}
return &poll, nil
}

View File

@ -47,13 +47,14 @@ type Status struct {
Application Application `json:"application"` Application Application `json:"application"`
Language string `json:"language"` Language string `json:"language"`
Pinned interface{} `json:"pinned"` Pinned interface{} `json:"pinned"`
Poll *Poll `json:"poll"`
// Custom fields // Custom fields
Pleroma StatusPleroma `json:"pleroma"` Pleroma StatusPleroma `json:"pleroma"`
ShowReplies bool `json:"show_replies"` ShowReplies bool `json:"show_replies"`
ReplyMap map[string][]ReplyInfo `json:"reply_map"` ReplyMap map[string][]ReplyInfo `json:"reply_map"`
ReplyNumber int `json:"reply_number"` ReplyNumber int `json:"reply_number"`
RetweetedByID string `json:"retweeted_by_id"` RetweetedByID string `json:"retweeted_by_id"`
} }
// Context hold information for mastodon context. // Context hold information for mastodon context.

View File

@ -43,6 +43,7 @@ func NewRenderer(templateGlobPattern string) (r *renderer, err error) {
"StatusContentFilter": StatusContentFilter, "StatusContentFilter": StatusContentFilter,
"DisplayInteractionCount": DisplayInteractionCount, "DisplayInteractionCount": DisplayInteractionCount,
"TimeSince": TimeSince, "TimeSince": TimeSince,
"TimeUntil": TimeUntil,
"FormatTimeRFC3339": FormatTimeRFC3339, "FormatTimeRFC3339": FormatTimeRFC3339,
"FormatTimeRFC822": FormatTimeRFC822, "FormatTimeRFC822": FormatTimeRFC822,
"WithContext": WithContext, "WithContext": WithContext,
@ -158,8 +159,7 @@ func DisplayInteractionCount(c int64) string {
return "" return ""
} }
func TimeSince(t time.Time) string { func DurToStr(dur time.Duration) string {
dur := time.Since(t)
s := dur.Seconds() s := dur.Seconds()
if s < 60 { if s < 60 {
return strconv.Itoa(int(s)) + "s" return strconv.Itoa(int(s)) + "s"
@ -184,6 +184,14 @@ func TimeSince(t time.Time) string {
return strconv.Itoa(int(y)) + "y" return strconv.Itoa(int(y)) + "y"
} }
func TimeSince(t time.Time) string {
return DurToStr(time.Since(t))
}
func TimeUntil(t time.Time) string {
return DurToStr(time.Until(t))
}
func FormatTimeRFC3339(t time.Time) string { func FormatTimeRFC3339(t time.Time) string {
return t.Format(time.RFC3339) return t.Format(time.RFC3339)
} }

View File

@ -250,6 +250,19 @@ func (s *as) UnRetweet(ctx context.Context, c *model.Client, id string) (count i
return s.Service.UnRetweet(ctx, c, id) return s.Service.UnRetweet(ctx, c, id)
} }
func (s *as) Vote(ctx context.Context, c *model.Client, id string,
choices []string) (err error) {
err = s.authenticateClient(ctx, c)
if err != nil {
return
}
err = checkCSRF(ctx, c)
if err != nil {
return
}
return s.Service.Vote(ctx, c, id, choices)
}
func (s *as) Follow(ctx context.Context, c *model.Client, id string) (err error) { func (s *as) Follow(ctx context.Context, c *model.Client, id string) (err error) {
err = s.authenticateClient(ctx, c) err = s.authenticateClient(ctx, c)
if err != nil { if err != nil {

View File

@ -111,7 +111,7 @@ func (s *ls) ServeSearchPage(ctx context.Context, c *model.Client, q string,
return s.Service.ServeSearchPage(ctx, c, q, qType, offset) return s.Service.ServeSearchPage(ctx, c, q, qType, offset)
} }
func (s *ls) ServeUserSearchPage(ctx context.Context, c *model.Client, func (s *ls) ServeUserSearchPage(ctx context.Context, c *model.Client,
id string, q string, offset int) (err error) { id string, q string, offset int) (err error) {
defer func(begin time.Time) { defer func(begin time.Time) {
s.logger.Printf("method=%v, took=%v, err=%v\n", s.logger.Printf("method=%v, took=%v, err=%v\n",
@ -189,6 +189,14 @@ func (s *ls) UnRetweet(ctx context.Context, c *model.Client, id string) (count i
return s.Service.UnRetweet(ctx, c, id) return s.Service.UnRetweet(ctx, c, id)
} }
func (s *ls) Vote(ctx context.Context, c *model.Client, id string, choices []string) (err error) {
defer func(begin time.Time) {
s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n",
"Vote", id, time.Since(begin), err)
}(time.Now())
return s.Service.Vote(ctx, c, id, choices)
}
func (s *ls) Follow(ctx context.Context, c *model.Client, id string) (err error) { func (s *ls) Follow(ctx context.Context, c *model.Client, id string) (err error) {
defer func(begin time.Time) { defer func(begin time.Time) {
s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n", s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n",

View File

@ -42,6 +42,7 @@ type Service interface {
UnLike(ctx context.Context, c *model.Client, id string) (count int64, err error) UnLike(ctx context.Context, c *model.Client, id string) (count int64, err error)
Retweet(ctx context.Context, c *model.Client, id string) (count int64, err error) Retweet(ctx context.Context, c *model.Client, id string) (count int64, err error)
UnRetweet(ctx context.Context, c *model.Client, id string) (count int64, err error) UnRetweet(ctx context.Context, c *model.Client, id string) (count int64, err error)
Vote(ctx context.Context, c *model.Client, id string, choices []string) (err error)
Follow(ctx context.Context, c *model.Client, id string) (err error) Follow(ctx context.Context, c *model.Client, id string) (err error)
UnFollow(ctx context.Context, c *model.Client, id string) (err error) UnFollow(ctx context.Context, c *model.Client, id string) (err error)
Mute(ctx context.Context, c *model.Client, id string) (err error) Mute(ctx context.Context, c *model.Client, id string) (err error)
@ -843,6 +844,15 @@ func (svc *service) UnRetweet(ctx context.Context, c *model.Client, id string) (
return return
} }
func (svc *service) Vote(ctx context.Context, c *model.Client, id string,
choices []string) (err error) {
_, err = c.Vote(ctx, id, choices)
if err != nil {
return
}
return
}
func (svc *service) Follow(ctx context.Context, c *model.Client, id string) (err error) { func (svc *service) Follow(ctx context.Context, c *model.Client, id string) (err error) {
_, err = c.AccountFollow(ctx, id) _, err = c.AccountFollow(ctx, id)
return return

View File

@ -419,6 +419,24 @@ func NewHandler(s Service, staticDir string) http.Handler {
w.WriteHeader(http.StatusFound) w.WriteHeader(http.StatusFound)
} }
vote := func(w http.ResponseWriter, req *http.Request) {
c := newClient(w)
ctx := newCtxWithSesionCSRF(req, req.FormValue("csrf_token"))
id, _ := mux.Vars(req)["id"]
statusID := req.FormValue("status_id")
choices, _ := req.PostForm["choices"]
err := s.Vote(ctx, c, id, choices)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
s.ServeErrorPage(ctx, c, err)
return
}
w.Header().Add("Location", req.Header.Get("Referer")+"#status-"+statusID)
w.WriteHeader(http.StatusFound)
}
follow := func(w http.ResponseWriter, req *http.Request) { follow := func(w http.ResponseWriter, req *http.Request) {
c := newClient(w) c := newClient(w)
ctx := newCtxWithSesionCSRF(req, req.FormValue("csrf_token")) ctx := newCtxWithSesionCSRF(req, req.FormValue("csrf_token"))
@ -697,6 +715,7 @@ func NewHandler(s Service, staticDir string) http.Handler {
r.HandleFunc("/unlike/{id}", unlike).Methods(http.MethodPost) r.HandleFunc("/unlike/{id}", unlike).Methods(http.MethodPost)
r.HandleFunc("/retweet/{id}", retweet).Methods(http.MethodPost) r.HandleFunc("/retweet/{id}", retweet).Methods(http.MethodPost)
r.HandleFunc("/unretweet/{id}", unretweet).Methods(http.MethodPost) r.HandleFunc("/unretweet/{id}", unretweet).Methods(http.MethodPost)
r.HandleFunc("/vote/{id}", vote).Methods(http.MethodPost)
r.HandleFunc("/follow/{id}", follow).Methods(http.MethodPost) r.HandleFunc("/follow/{id}", follow).Methods(http.MethodPost)
r.HandleFunc("/unfollow/{id}", unfollow).Methods(http.MethodPost) r.HandleFunc("/unfollow/{id}", unfollow).Methods(http.MethodPost)
r.HandleFunc("/mute/{id}", mute).Methods(http.MethodPost) r.HandleFunc("/mute/{id}", mute).Methods(http.MethodPost)

View File

@ -452,6 +452,14 @@ a:hover,
margin: 2px; margin: 2px;
} }
.poll-form button[type=submit] {
margin-top: 6px;
}
.poll-info {
margin-top: 6px;
}
.dark { .dark {
background-color: #222222; background-color: #222222;
background-image: none; background-image: none;

View File

@ -25,11 +25,11 @@
<span class="status-uname"> {{.Account.Acct}} </span> <span class="status-uname"> {{.Account.Acct}} </span>
</a> </a>
<div class="more-container" title="more"> <div class="more-container" title="more">
<div class="remote-link" title="mute"> <div class="remote-link">
{{.Visibility}} {{.Visibility}}
</div> </div>
<div class="more-content"> <div class="more-content">
<a class="more-link" href="{{.URL}}" target="_blank" title="mute"> <a class="more-link" href="{{.URL}}" target="_blank" title="source">
source source
</a> </a>
{{if .Muted}} {{if .Muted}}
@ -74,41 +74,76 @@
<div class="status-content"> {{StatusContentFilter .SpoilerText .Content .Emojis .Mentions}} </div> <div class="status-content"> {{StatusContentFilter .SpoilerText .Content .Emojis .Mentions}} </div>
{{end}} {{end}}
<div class="status-media-container"> <div class="status-media-container">
{{range .MediaAttachments}} {{range .MediaAttachments}}
{{if eq .Type "image"}} {{if eq .Type "image"}}
<a class="img-link" href="{{.URL}}" target="_blank"> <a class="img-link" href="{{.URL}}" target="_blank">
<img class="status-image" src="{{.URL}}" alt="status-image" /> <img class="status-image" src="{{.URL}}" alt="status-image" />
{{if (and $.Ctx.MaskNSFW $s.Sensitive)}} {{if (and $.Ctx.MaskNSFW $s.Sensitive)}}
<div class="status-nsfw-overlay"></div> <div class="status-nsfw-overlay"></div>
{{end}} {{end}}
</a> </a>
{{else if eq .Type "audio"}} {{else if eq .Type "audio"}}
<audio class="status-audio" controls preload="none"> <audio class="status-audio" controls preload="none">
<source src="{{.URL}}">
<p> Your browser doesn't support HTML5 audio </p>
</audio>
{{else if eq .Type "video"}}
<div class="status-video-container">
<video class="status-video" controls preload="none">
<source src="{{.URL}}"> <source src="{{.URL}}">
<p> Your browser doesn't support HTML5 video </p> <p> Your browser doesn't support HTML5 audio </p>
</video> </audio>
{{if (and $.Ctx.MaskNSFW $s.Sensitive)}} {{else if eq .Type "video"}}
<div class="status-nsfw-overlay"></div> <div class="status-video-container">
<video class="status-video" controls preload="none">
<source src="{{.URL}}">
<p> Your browser doesn't support HTML5 video </p>
</video>
{{if (and $.Ctx.MaskNSFW $s.Sensitive)}}
<div class="status-nsfw-overlay"></div>
{{end}}
</div>
{{else}}
<a href="{{.URL}}" target="_blank"> attachment </a>
{{end}}
{{end}} {{end}}
</div> </div>
{{else}} {{if .Poll}}
<a href="{{.URL}}" target="_blank"> attachment </a> <form class="poll-form" action="/vote/{{.Poll.ID}}" method="POST">
<input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}">
<input type="hidden" name="status_id" value="{{$s.ID}}">
{{range $i, $o := .Poll.Options}}
<div class="poll-option">
{{if (or $s.Poll.Expired $s.Poll.Voted)}}
<div> {{$o.Title}} - {{$o.VotesCount}} votes </div>
{{else}}
<input type="{{if $s.Poll.Multiple}}checkbox{{else}}radio{{end}}" name="choices"
id="poll-{{$s.ID}}-{{$i}}" value="{{$i}}">
<label for="poll-{{$s.ID}}-{{$i}}">
{{$o.Title}}
</label>
{{end}}
</div>
{{end}}
{{if not (or .Poll.Expired .Poll.Voted)}}
<button type="submit"> Vote </button>
{{end}}
<div class="poll-info">
<span>{{.Poll.VotesCount}} votes</span>
{{if .Poll.Expired}}
<span> - poll expired </span>
{{else}}
<span>
- poll ends in
<time datetime="{{FormatTimeRFC3339 .Poll.ExpiresAt}}" title="{{FormatTimeRFC822 .Poll.ExpiresAt}}">
{{TimeUntil .Poll.ExpiresAt}}
</time>
</span>
{{end}}
</div>
</form>
{{end}} {{end}}
{{end}}
</div>
<div class="status-action-container"> <div class="status-action-container">
<div class="status-action"> <div class="status-action">
<a href="/thread/{{.ID}}?reply=true#status-{{.ID}}" title="reply"> <a href="/thread/{{.ID}}?reply=true#status-{{.ID}}" title="reply">
reply reply
</a> </a>
<a class="status-reply-count" href="/thread/{{.ID}}#status-{{.ID}}" {{if $.Ctx.ThreadInNewTab}}target="_blank"{{end}}> <a class="status-reply-count" href="/thread/{{.ID}}#status-{{.ID}}" {{if $.Ctx.ThreadInNewTab}}target="_blank"{{end}}>
{{if .RepliesCount}} ({{DisplayInteractionCount .RepliesCount}}) {{end}} {{if .RepliesCount}} ({{DisplayInteractionCount .RepliesCount}}) {{end}}
</a> </a>
</div> </div>
<div class="status-action"> <div class="status-action">
@ -154,8 +189,11 @@
</a> </a>
</div> </div>
<div class="status-action"> <div class="status-action">
<a class="status-time" href="{{if not .ShowReplies}}/thread/{{.ID}}{{end}}#status-{{.ID}}" {{if $.Ctx.ThreadInNewTab}}target="_blank"{{end}}> <a class="status-time" href="{{if not .ShowReplies}}/thread/{{.ID}}{{end}}#status-{{.ID}}"
<time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}"> {{TimeSince .CreatedAt}} </time> {{if $.Ctx.ThreadInNewTab}}target="_blank"{{end}}>
<time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}">
{{TimeSince .CreatedAt}}
</time>
</a> </a>
</div> </div>
</div> </div>