Add poll support
authorr <r@freesoftwareextremist.com>
Sun, 9 Feb 2020 13:42:16 +0000 (13:42 +0000)
committerr <r@freesoftwareextremist.com>
Sun, 9 Feb 2020 13:42:16 +0000 (13:42 +0000)
Currenlty only voting is possible.

mastodon/poll.go [new file with mode: 0644]
mastodon/status.go
renderer/renderer.go
service/auth.go
service/logging.go
service/service.go
service/transport.go
static/style.css
templates/status.tmpl

diff --git a/mastodon/poll.go b/mastodon/poll.go
new file mode 100644 (file)
index 0000000..274b95e
--- /dev/null
@@ -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
+}
index 63de3cf814fa6bc072beb95eb8df0154bff1476a..b4f57ae308dbb68cc888fdfcd45b8b5531f4faca 100644 (file)
@@ -47,13 +47,14 @@ type Status struct {
        Application        Application  `json:"application"`
        Language           string       `json:"language"`
        Pinned             interface{}  `json:"pinned"`
+       Poll               *Poll        `json:"poll"`
 
        // Custom fields
-       Pleroma         StatusPleroma          `json:"pleroma"`
-       ShowReplies     bool                   `json:"show_replies"`
-       ReplyMap        map[string][]ReplyInfo `json:"reply_map"`
-       ReplyNumber     int                    `json:"reply_number"`
-       RetweetedByID   string                 `json:"retweeted_by_id"`
+       Pleroma       StatusPleroma          `json:"pleroma"`
+       ShowReplies   bool                   `json:"show_replies"`
+       ReplyMap      map[string][]ReplyInfo `json:"reply_map"`
+       ReplyNumber   int                    `json:"reply_number"`
+       RetweetedByID string                 `json:"retweeted_by_id"`
 }
 
 // Context hold information for mastodon context.
index 0eb229c19e0c88545d3892283f7ed8a83664e8b7..bd9ccd8432d51e219ff9a5686416e477c277ee42 100644 (file)
@@ -43,6 +43,7 @@ func NewRenderer(templateGlobPattern string) (r *renderer, err error) {
                "StatusContentFilter":     StatusContentFilter,
                "DisplayInteractionCount": DisplayInteractionCount,
                "TimeSince":               TimeSince,
+               "TimeUntil":               TimeUntil,
                "FormatTimeRFC3339":       FormatTimeRFC3339,
                "FormatTimeRFC822":        FormatTimeRFC822,
                "WithContext":             WithContext,
@@ -86,7 +87,7 @@ func (r *renderer) RenderUserPage(ctx *Context, writer io.Writer,
        return r.template.ExecuteTemplate(writer, "user.tmpl", WithContext(data, ctx))
 }
 
-func (r *renderer) RenderUserSearchPage(ctx *Context, writer io.Writer, 
+func (r *renderer) RenderUserSearchPage(ctx *Context, writer io.Writer,
        data *UserSearchData) (err error) {
        return r.template.ExecuteTemplate(writer, "usersearch.tmpl", WithContext(data, ctx))
 }
@@ -158,8 +159,7 @@ func DisplayInteractionCount(c int64) string {
        return ""
 }
 
-func TimeSince(t time.Time) string {
-       dur := time.Since(t)
+func DurToStr(dur time.Duration) string {
        s := dur.Seconds()
        if s < 60 {
                return strconv.Itoa(int(s)) + "s"
@@ -184,6 +184,14 @@ func TimeSince(t time.Time) string {
        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 {
        return t.Format(time.RFC3339)
 }
index 6c714399eabaadbb799acc242861abcc8b1607a3..4c5b38b8c517bbf523cd9bf02788604830c0ff20 100644 (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)
 }
 
+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) {
        err = s.authenticateClient(ctx, c)
        if err != nil {
index e429fac83026debb5076a5b6731d35cd0de73200..055daddc55d73731c5ca2dac1a8c135770472ecf 100644 (file)
@@ -77,7 +77,7 @@ func (s *ls) ServeNotificationPage(ctx context.Context, c *model.Client,
        return s.Service.ServeNotificationPage(ctx, c, maxID, minID)
 }
 
-func (s *ls) ServeUserPage(ctx context.Context, c *model.Client, id string, 
+func (s *ls) ServeUserPage(ctx context.Context, c *model.Client, id string,
        pageType string, maxID string, minID string) (err error) {
        defer func(begin time.Time) {
                s.logger.Printf("method=%v, id=%v, type=%v, took=%v, err=%v\n",
@@ -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)
 }
 
-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) {
        defer func(begin time.Time) {
                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)
 }
 
+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) {
        defer func(begin time.Time) {
                s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n",
index d762842446f8eaec36c1946af0edb44866cf4589..ecd0d3fb77db0b439a800a23e84db325182eae64 100644 (file)
@@ -42,6 +42,7 @@ type Service interface {
        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)
        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)
        UnFollow(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
 }
 
+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) {
        _, err = c.AccountFollow(ctx, id)
        return
index 81af4fa2326a5961ec1d7e5c80030e2a01e2a973..5ce0e5670f918ccb6d2a3a8cecd3bc7b4b65437e 100644 (file)
@@ -419,6 +419,24 @@ func NewHandler(s Service, staticDir string) http.Handler {
                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) {
                c := newClient(w)
                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("/retweet/{id}", retweet).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("/unfollow/{id}", unfollow).Methods(http.MethodPost)
        r.HandleFunc("/mute/{id}", mute).Methods(http.MethodPost)
index dc3f285c73f5ae1c92fd3f2353292e7e53983e93..2f80af92ac1208b19fb2451b22ef865937bfd6fe 100644 (file)
@@ -452,6 +452,14 @@ a:hover,
        margin: 2px;
 }
 
+.poll-form button[type=submit] {
+       margin-top: 6px;
+}
+
+.poll-info {
+       margin-top: 6px;
+}
+
 .dark {
        background-color: #222222;
        background-image: none;
index 75b399b0941543deddac14356c5554ab3dbe8c19..95dee206a7e16e6709b37455556e6f2689796b78 100644 (file)
                                        <span class="status-uname"> {{.Account.Acct}} </span>
                                </a>
                                <div class="more-container" title="more">
-                                       <div class="remote-link" title="mute">
+                                       <div class="remote-link">
                                                {{.Visibility}}
                                        </div>
                                        <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
                                                </a>
                                                {{if .Muted}}
                        <div class="status-content"> {{StatusContentFilter .SpoilerText .Content .Emojis .Mentions}} </div>
                        {{end}}
                        <div class="status-media-container">
-                       {{range .MediaAttachments}}
-                       {{if eq .Type "image"}}
-                       <a class="img-link" href="{{.URL}}" target="_blank">
-                               <img class="status-image" src="{{.URL}}" alt="status-image" />
-                               {{if (and $.Ctx.MaskNSFW $s.Sensitive)}}
-                               <div class="status-nsfw-overlay"></div>
-                               {{end}}
-                       </a>
-                       {{else if eq .Type "audio"}}
-                       <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">
+                               {{range .MediaAttachments}}
+                               {{if eq .Type "image"}}
+                               <a class="img-link" href="{{.URL}}" target="_blank">
+                                       <img class="status-image" src="{{.URL}}" alt="status-image" />
+                                       {{if (and $.Ctx.MaskNSFW $s.Sensitive)}}
+                                       <div class="status-nsfw-overlay"></div>
+                                       {{end}}
+                               </a>
+                               {{else if eq .Type "audio"}}
+                               <audio class="status-audio" 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>
+                                       <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}}">
+                                               <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}}
                        </div>
-                       {{else}}
-                       <a href="{{.URL}}" target="_blank"> attachment </a>
-                       {{end}}
+                       {{if .Poll}}
+                       <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}}
-                       </div>
                        <div class="status-action-container"> 
                                <div class="status-action">
                                        <a href="/thread/{{.ID}}?reply=true#status-{{.ID}}" title="reply"> 
                                                reply
                                        </a>
                                        <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>
                                </div>
                                <div class="status-action">
                                        </a>
                                </div>
                                <div class="status-action">
-                                       <a class="status-time" href="{{if not .ShowReplies}}/thread/{{.ID}}{{end}}#status-{{.ID}}" {{if $.Ctx.ThreadInNewTab}}target="_blank"{{end}}> 
-                                               <time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}"> {{TimeSince .CreatedAt}} </time> 
+                                       <a class="status-time" href="{{if not .ShowReplies}}/thread/{{.ID}}{{end}}#status-{{.ID}}" 
+                                               {{if $.Ctx.ThreadInNewTab}}target="_blank"{{end}}> 
+                                               <time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}"> 
+                                                       {{TimeSince .CreatedAt}}
+                                               </time> 
                                        </a>
                                </div>
                        </div>