Add notification support
authorr <r@freesoftwareextremist.com>
Sun, 15 Dec 2019 17:37:58 +0000 (17:37 +0000)
committerr <r@freesoftwareextremist.com>
Sun, 15 Dec 2019 17:37:58 +0000 (17:37 +0000)
13 files changed:
mastodon/notification.go
renderer/model.go
renderer/renderer.go
service/auth.go
service/logging.go
service/service.go
service/transport.go
static/main.css
templates/navigation.tmpl
templates/notification.tmpl [new file with mode: 0644]
templates/status.tmpl
templates/thread.tmpl
templates/timeline.tmpl

index 236fcbffb89e2252df895f19337de04cd92c47fe..d793905be8d27080f3b2b6dacf3c47fb2dfd0c00 100644 (file)
@@ -4,16 +4,22 @@ import (
        "context"
        "fmt"
        "net/http"
+       "net/url"
        "time"
 )
 
+type NotificationPleroma struct {
+       IsSeen bool `json:"is_seen"`
+}
+
 // Notification hold information for mastodon notification.
 type Notification struct {
-       ID        string    `json:"id"`
-       Type      string    `json:"type"`
-       CreatedAt time.Time `json:"created_at"`
-       Account   Account   `json:"account"`
-       Status    *Status   `json:"status"`
+       ID        string               `json:"id"`
+       Type      string               `json:"type"`
+       CreatedAt time.Time            `json:"created_at"`
+       Account   Account              `json:"account"`
+       Status    *Status              `json:"status"`
+       Pleroma   *NotificationPleroma `json:"pleroma"`
 }
 
 // GetNotifications return notifications.
@@ -40,3 +46,11 @@ func (c *Client) GetNotification(ctx context.Context, id string) (*Notification,
 func (c *Client) ClearNotifications(ctx context.Context) error {
        return c.doAPI(ctx, http.MethodPost, "/api/v1/notifications/clear", nil, nil, nil)
 }
+
+// ReadNotifications marks notifications as read
+// Currenly only works for Pleroma
+func (c *Client) ReadNotifications(ctx context.Context, maxID string) error {
+       params := url.Values{}
+       params.Set("max_id", maxID)
+       return c.doAPI(ctx, http.MethodPost, "/api/v1/pleroma/notifications/read", params, nil, nil)
+}
index 6f6acc46d9e5e8f86abec22c3faeba81e5bbd391..45293867e2babd9d3c8fbfa4a89d2c068db15ddd 100644 (file)
@@ -4,22 +4,34 @@ import (
        "mastodon"
 )
 
+type NavbarTemplateData struct {
+       NotificationCount int
+}
+
+func NewNavbarTemplateData(notificationCount int) *NavbarTemplateData {
+       return &NavbarTemplateData{
+               NotificationCount: notificationCount,
+       }
+}
+
 type TimelinePageTemplateData struct {
-       Statuses []*mastodon.Status
-       HasNext  bool
-       NextLink string
-       HasPrev  bool
-       PrevLink string
+       Statuses   []*mastodon.Status
+       HasNext    bool
+       NextLink   string
+       HasPrev    bool
+       PrevLink   string
+       NavbarData *NavbarTemplateData
 }
 
 func NewTimelinePageTemplateData(statuses []*mastodon.Status, hasNext bool, nextLink string, hasPrev bool,
-       prevLink string) *TimelinePageTemplateData {
+       prevLink string, navbarData *NavbarTemplateData) *TimelinePageTemplateData {
        return &TimelinePageTemplateData{
-               Statuses: statuses,
-               HasNext:  hasNext,
-               NextLink: nextLink,
-               HasPrev:  hasPrev,
-               PrevLink: prevLink,
+               Statuses:   statuses,
+               HasNext:    hasNext,
+               NextLink:   nextLink,
+               HasPrev:    hasPrev,
+               PrevLink:   prevLink,
+               NavbarData: navbarData,
        }
 }
 
@@ -29,14 +41,32 @@ type ThreadPageTemplateData struct {
        PostReply    bool
        ReplyToID    string
        ReplyContent string
+       NavbarData   *NavbarTemplateData
 }
 
-func NewThreadPageTemplateData(status *mastodon.Status, context *mastodon.Context, postReply bool, replyToID string, replyContent string) *ThreadPageTemplateData {
+func NewThreadPageTemplateData(status *mastodon.Status, context *mastodon.Context, postReply bool, replyToID string, replyContent string, navbarData *NavbarTemplateData) *ThreadPageTemplateData {
        return &ThreadPageTemplateData{
                Status:       status,
                Context:      context,
                PostReply:    postReply,
                ReplyToID:    replyToID,
                ReplyContent: replyContent,
+               NavbarData:   navbarData,
+       }
+}
+
+type NotificationPageTemplateData struct {
+       Notifications []*mastodon.Notification
+       HasNext       bool
+       NextLink      string
+       NavbarData    *NavbarTemplateData
+}
+
+func NewNotificationPageTemplateData(notifications []*mastodon.Notification, hasNext bool, nextLink string, navbarData *NavbarTemplateData) *NotificationPageTemplateData {
+       return &NotificationPageTemplateData{
+               Notifications: notifications,
+               HasNext:       hasNext,
+               NextLink:      nextLink,
+               NavbarData:    navbarData,
        }
 }
index c3d35265abe0ee80ba22c954ab4e2415e50689e0..394d74fb2210f311968fcfd770f334e540142692 100644 (file)
@@ -17,6 +17,7 @@ type Renderer interface {
        RenderSigninPage(ctx context.Context, writer io.Writer) (err error)
        RenderTimelinePage(ctx context.Context, writer io.Writer, data *TimelinePageTemplateData) (err error)
        RenderThreadPage(ctx context.Context, writer io.Writer, data *ThreadPageTemplateData) (err error)
+       RenderNotificationPage(ctx context.Context, writer io.Writer, data *NotificationPageTemplateData) (err error)
 }
 
 type renderer struct {
@@ -60,6 +61,10 @@ func (r *renderer) RenderThreadPage(ctx context.Context, writer io.Writer, data
        return r.template.ExecuteTemplate(writer, "thread.tmpl", data)
 }
 
+func (r *renderer) RenderNotificationPage(ctx context.Context, writer io.Writer, data *NotificationPageTemplateData) (err error) {
+       return r.template.ExecuteTemplate(writer, "notification.tmpl", data)
+}
+
 func WithEmojis(content string, emojis []mastodon.Emoji) string {
        var emojiNameContentPair []string
        for _, e := range emojis {
index 98012aff608a7c988911cce3467ff0e4311f30bc..e9bec3801caf02fbe4cbb2fa9224acec5c7cca61 100644 (file)
@@ -111,6 +111,14 @@ func (s *authService) ServeThreadPage(ctx context.Context, client io.Writer, c *
        return s.Service.ServeThreadPage(ctx, client, c, id, reply)
 }
 
+func (s *authService) ServeNotificationPage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, minID string) (err error) {
+       c, err = s.getClient(ctx)
+       if err != nil {
+               return
+       }
+       return s.Service.ServeNotificationPage(ctx, client, c, maxID, minID)
+}
+
 func (s *authService) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
        c, err = s.getClient(ctx)
        if err != nil {
index 3a95a940f5e4d3eff60933f926fe3a4e9f7ca589..aa1da687b05d616555e1c4839956fbbc2dd86f7f 100644 (file)
@@ -77,6 +77,14 @@ func (s *loggingService) ServeThreadPage(ctx context.Context, client io.Writer,
        return s.Service.ServeThreadPage(ctx, client, c, id, reply)
 }
 
+func (s *loggingService) ServeNotificationPage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, minID string) (err error) {
+       defer func(begin time.Time) {
+               s.logger.Printf("method=%v, max_id=%v, min_id=%v, took=%v, err=%v\n",
+                       "ServeNotificationPage", maxID, minID, time.Since(begin), err)
+       }(time.Now())
+       return s.Service.ServeNotificationPage(ctx, client, c, maxID, minID)
+}
+
 func (s *loggingService) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
        defer func(begin time.Time) {
                s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n",
index e502b65f3c86da1253b12fc6022f2056cd6d2051..93f22fa2e296e88b6b8b272cf313672f439c5ad5 100644 (file)
@@ -5,7 +5,6 @@ import (
        "context"
        "encoding/json"
        "errors"
-       "fmt"
        "io"
        "mime/multipart"
        "net/http"
@@ -33,6 +32,7 @@ type Service interface {
        ServeSigninPage(ctx context.Context, client io.Writer) (err error)
        ServeTimelinePage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, sinceID string, minID string) (err error)
        ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error)
+       ServeNotificationPage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, minID string) (err error)
        Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
        UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
        Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
@@ -219,7 +219,7 @@ func (svc *service) ServeTimelinePage(ctx context.Context, client io.Writer,
 
        if len(maxID) > 0 && len(statuses) > 0 {
                hasPrev = true
-               prevLink = fmt.Sprintf("/timeline?min_id=%s", statuses[0].ID)
+               prevLink = "/timeline?min_id=" + statuses[0].ID
        }
        if len(minID) > 0 && len(pg.MinID) > 0 {
                newStatuses, err := c.GetTimelineHome(ctx, &mastodon.Pagination{MinID: pg.MinID, Limit: 20})
@@ -229,21 +229,26 @@ func (svc *service) ServeTimelinePage(ctx context.Context, client io.Writer,
                newStatusesLen := len(newStatuses)
                if newStatusesLen == 20 {
                        hasPrev = true
-                       prevLink = fmt.Sprintf("/timeline?min_id=%s", pg.MinID)
+                       prevLink = "/timeline?min_id=" + pg.MinID
                } else {
                        i := 20 - newStatusesLen - 1
                        if len(statuses) > i {
                                hasPrev = true
-                               prevLink = fmt.Sprintf("/timeline?min_id=%s", statuses[i].ID)
+                               prevLink = "/timeline?min_id=" + statuses[i].ID
                        }
                }
        }
        if len(pg.MaxID) > 0 {
                hasNext = true
-               nextLink = fmt.Sprintf("/timeline?max_id=%s", pg.MaxID)
+               nextLink = "/timeline?max_id=" + pg.MaxID
        }
 
-       data := renderer.NewTimelinePageTemplateData(statuses, hasNext, nextLink, hasPrev, prevLink)
+       navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
+       if err != nil {
+               return
+       }
+
+       data := renderer.NewTimelinePageTemplateData(statuses, hasNext, nextLink, hasPrev, prevLink, navbarData)
        err = svc.renderer.RenderTimelinePage(ctx, client, data)
        if err != nil {
                return
@@ -280,7 +285,12 @@ func (svc *service) ServeThreadPage(ctx context.Context, client io.Writer, c *ma
                }
        }
 
-       data := renderer.NewThreadPageTemplateData(status, context, reply, id, content)
+       navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
+       if err != nil {
+               return
+       }
+
+       data := renderer.NewThreadPageTemplateData(status, context, reply, id, content, navbarData)
        err = svc.renderer.RenderThreadPage(ctx, client, data)
        if err != nil {
                return
@@ -289,6 +299,78 @@ func (svc *service) ServeThreadPage(ctx context.Context, client io.Writer, c *ma
        return
 }
 
+func (svc *service) ServeNotificationPage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, minID string) (err error) {
+       var hasNext bool
+       var nextLink string
+
+       var pg = mastodon.Pagination{
+               MaxID: maxID,
+               MinID: minID,
+               Limit: 20,
+       }
+
+       notifications, err := c.GetNotifications(ctx, &pg)
+       if err != nil {
+               return
+       }
+
+       var unreadCount int
+       for i := range notifications {
+               switch notifications[i].Type {
+               case "reblog", "favourite":
+                       if notifications[i].Status != nil {
+                               notifications[i].Status.Account.ID = ""
+                       }
+               }
+               if notifications[i].Pleroma != nil && notifications[i].Pleroma.IsSeen {
+                       unreadCount++
+               }
+       }
+
+       if unreadCount > 0 {
+               err := c.ReadNotifications(ctx, notifications[0].ID)
+               if err != nil {
+                       return err
+               }
+       }
+
+       if len(pg.MaxID) > 0 {
+               hasNext = true
+               nextLink = "/notifications?max_id=" + pg.MaxID
+       }
+
+       navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
+       if err != nil {
+               return
+       }
+
+       data := renderer.NewNotificationPageTemplateData(notifications, hasNext, nextLink, navbarData)
+       err = svc.renderer.RenderNotificationPage(ctx, client, data)
+       if err != nil {
+               return
+       }
+
+       return
+}
+
+func (svc *service) getNavbarTemplateData(ctx context.Context, client io.Writer, c *mastodon.Client) (data *renderer.NavbarTemplateData, err error) {
+       notifications, err := c.GetNotifications(ctx, nil)
+       if err != nil {
+               return
+       }
+
+       var notificationCount int
+       for i := range notifications {
+               if notifications[i].Pleroma != nil && !notifications[i].Pleroma.IsSeen {
+                       notificationCount++
+               }
+       }
+
+       data = renderer.NewNavbarTemplateData(notificationCount)
+
+       return
+}
+
 func (svc *service) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
        _, err = c.Favourite(ctx, id)
        return
index d5a6ee8e4967b44363ec38852a440bca443e36ed..377ab23e64f81dbfb1db41c03bedabfbcbf993c2 100644 (file)
@@ -179,6 +179,19 @@ func NewHandler(s Service, staticDir string) http.Handler {
                w.WriteHeader(http.StatusSeeOther)
        }).Methods(http.MethodPost)
 
+       r.HandleFunc("/notifications", func(w http.ResponseWriter, req *http.Request) {
+               ctx := getContextWithSession(context.Background(), req)
+
+               maxID := req.URL.Query().Get("max_id")
+               minID := req.URL.Query().Get("min_id")
+
+               err := s.ServeNotificationPage(ctx, w, nil, maxID, minID)
+               if err != nil {
+                       s.ServeErrorPage(ctx, w, err)
+                       return
+               }
+       }).Methods(http.MethodGet)
+
        r.HandleFunc("/signout", func(w http.ResponseWriter, req *http.Request) {
                // TODO remove session from database
                w.Header().Add("Set-Cookie", fmt.Sprintf("session_id=;max-age=0"))
index 3f551cc7f90aae102eb198a243630f387ab3c41b..8865820c58b14a71ee62b5378a9dc5bcffb6e511 100644 (file)
 .status-profile-img {
        height: 48px;
        width: 48px;
-       object-fit: contain;
+       margin-right: 8px;
 }
 
 .status {
-       margin: 0 8px;
 }
 
 .status a {
        height: 24px;
        width: 24px;
        margin-bottom: -8px;
-       object-fit: contain;
 }
 
 .retweet-info .status-dname{
 .pagination a {
        margin: 0 8px;
 }
+
+.notification-container {
+       margin: 4px 0;
+       padding: 4px 4px;
+       border-left: 4px solid transparent;
+}
+
+.notification-container.unread {
+       border-color: #777777;
+}
+
+.notification-follow-container,
+.notification-like-container,
+.notification-retweet-container {
+       display: flex;
+}
+
+.notification-follow-uname {
+       margin-top: 8px;
+}
index ea4a2132dd32de157a8b4b54fec90d272a6e5628..d86971c920419d2709be1fcb88e53172983fe2b3 100644 (file)
@@ -1,4 +1,5 @@
 <div class="navigation">
        <a href="/timeline">home</a>
+       <a href="/notifications">notifications{{if gt .NotificationCount 0}} ({{.NotificationCount}}){{end}}</a>
        <a href="/signout">sign out</a>
 </div>
diff --git a/templates/notification.tmpl b/templates/notification.tmpl
new file mode 100644 (file)
index 0000000..099f17e
--- /dev/null
@@ -0,0 +1,59 @@
+{{template "header.tmpl"}}
+{{template "navigation.tmpl" .NavbarData}}
+<div class="page-title"> Notifications </div>
+
+{{range .Notifications}}
+<div class="notification-container {{if .Pleroma}}{{if not .Pleroma.IsSeen}}unread{{end}}{{end}}">
+       {{if eq .Type "follow"}}
+       <div class="notification-follow-container">
+               <img class="status-profile-img" src="{{.Account.AvatarStatic}}" alt="profile-avatar" />
+               <div>
+                       <div>
+                               <span class="status-dname"> {{WithEmojis .Account.DisplayName .Account.Emojis}} </span>  
+                               <span class="icon dripicons-user-group"></span> 
+                               followed you
+                       </div>
+                       <div class="notification-follow-uname">
+                               @{{.Account.Acct}}
+                       </div>
+               </div>
+       </div>
+
+       {{else if eq .Type "mention"}}
+       {{template "status" .Status}}
+
+       {{else if eq .Type "reblog"}}
+       <div class="notification-retweet-container">
+               <img class="status-profile-img" src="{{.Account.AvatarStatic}}" alt="profile-avatar" />
+               <div>
+                       <div>
+                               <span class="status-dname"> {{WithEmojis .Account.DisplayName .Account.Emojis}} </span>  
+                               <span class="icon dripicons-retweet retweeted"></span> 
+                               retweeted your post
+                       </div>
+                       {{template "status" .Status}}
+               </div>
+       </div>
+
+       {{else if eq .Type "favourite"}}
+       <div class="notification-like-container">
+               <img class="status-profile-img" src="{{.Account.AvatarStatic}}" alt="profile-avatar" />
+               <div>
+                       <div>
+                               <span class="status-dname"> {{WithEmojis .Account.DisplayName .Account.Emojis}} </span>  
+                               <span class="icon dripicons-star liked"></span> 
+                               liked your post
+                       </div>
+                       {{template "status" .Status}}
+               </div>
+       </div>
+       {{end}}
+</div>
+{{end}}
+
+<div class="pagination">
+       {{if .HasNext}}
+               <a href="{{.NextLink}}">next</a>
+       {{end}}
+</div>
+{{template "footer.tmpl"}}
index 24f9a540d23a3c1015f7ca40ca7fd54f308f505b..7020be073025b2ab19d17dadbf88535474092b24 100644 (file)
        {{block "status" .}}
        <div class="status-container">
                <div>
+                       {{if ne .Account.ID ""}}
                        <img class="status-profile-img" src="{{.Account.AvatarStatic}}" alt="profile-avatar" />
+                       {{end}}
                </div>
                <div class="status"> 
+                       {{if ne .Account.ID ""}}
                        <div class="status-name">
                                <span class="status-dname"> {{WithEmojis .Account.DisplayName .Account.Emojis}} </span> 
                                <span class="status-uname"> {{.Account.Acct}} </span>
                        </div>
+                       {{end}}
                        <div class="status-content"> {{WithEmojis .Content .Emojis}} </div>
                        <div class="status-media-container">
                        {{range .MediaAttachments}}
index a3f79162adceae4ceda84dcbbbe6119da8b4cbf5..29d702b8b76e2fd8dafa4eef3d3d0cced174565c 100644 (file)
@@ -1,6 +1,6 @@
 {{template "header.tmpl"}}
+{{template "navigation.tmpl" .NavbarData}}
 <div class="page-title"> Thread </div>
-{{template "navigation.tmpl"}}
 
 {{range .Context.Ancestors}}
 {{template "status.tmpl" .}}
index 527c91b02f30dc1be91b4309b4270a0e32ac1277..53e3ad7f9db542fd295a01306d36ab75b6e1f4b4 100644 (file)
@@ -1,6 +1,6 @@
 {{template "header.tmpl"}}
+{{template "navigation.tmpl" .NavbarData}}
 <div class="page-title"> Timeline </div>
-{{template "navigation.tmpl"}}
 
 <form class="timeline-post-form" action="/post" method="POST" enctype="multipart/form-data">
        <label for="post-content"> New Post </label>