Add notification support

This commit is contained in:
r 2019-12-15 17:37:58 +00:00
parent 51a4b16af5
commit f68d72ae0e
13 changed files with 271 additions and 29 deletions

View File

@ -4,9 +4,14 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"time" "time"
) )
type NotificationPleroma struct {
IsSeen bool `json:"is_seen"`
}
// Notification hold information for mastodon notification. // Notification hold information for mastodon notification.
type Notification struct { type Notification struct {
ID string `json:"id"` ID string `json:"id"`
@ -14,6 +19,7 @@ type Notification struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Account Account `json:"account"` Account Account `json:"account"`
Status *Status `json:"status"` Status *Status `json:"status"`
Pleroma *NotificationPleroma `json:"pleroma"`
} }
// GetNotifications return notifications. // 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 { func (c *Client) ClearNotifications(ctx context.Context) error {
return c.doAPI(ctx, http.MethodPost, "/api/v1/notifications/clear", nil, nil, nil) 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)
}

View File

@ -4,22 +4,34 @@ import (
"mastodon" "mastodon"
) )
type NavbarTemplateData struct {
NotificationCount int
}
func NewNavbarTemplateData(notificationCount int) *NavbarTemplateData {
return &NavbarTemplateData{
NotificationCount: notificationCount,
}
}
type TimelinePageTemplateData struct { type TimelinePageTemplateData struct {
Statuses []*mastodon.Status Statuses []*mastodon.Status
HasNext bool HasNext bool
NextLink string NextLink string
HasPrev bool HasPrev bool
PrevLink string PrevLink string
NavbarData *NavbarTemplateData
} }
func NewTimelinePageTemplateData(statuses []*mastodon.Status, hasNext bool, nextLink string, hasPrev bool, func NewTimelinePageTemplateData(statuses []*mastodon.Status, hasNext bool, nextLink string, hasPrev bool,
prevLink string) *TimelinePageTemplateData { prevLink string, navbarData *NavbarTemplateData) *TimelinePageTemplateData {
return &TimelinePageTemplateData{ return &TimelinePageTemplateData{
Statuses: statuses, Statuses: statuses,
HasNext: hasNext, HasNext: hasNext,
NextLink: nextLink, NextLink: nextLink,
HasPrev: hasPrev, HasPrev: hasPrev,
PrevLink: prevLink, PrevLink: prevLink,
NavbarData: navbarData,
} }
} }
@ -29,14 +41,32 @@ type ThreadPageTemplateData struct {
PostReply bool PostReply bool
ReplyToID string ReplyToID string
ReplyContent 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{ return &ThreadPageTemplateData{
Status: status, Status: status,
Context: context, Context: context,
PostReply: postReply, PostReply: postReply,
ReplyToID: replyToID, ReplyToID: replyToID,
ReplyContent: replyContent, 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,
} }
} }

View File

@ -17,6 +17,7 @@ type Renderer interface {
RenderSigninPage(ctx context.Context, writer io.Writer) (err error) RenderSigninPage(ctx context.Context, writer io.Writer) (err error)
RenderTimelinePage(ctx context.Context, writer io.Writer, data *TimelinePageTemplateData) (err error) RenderTimelinePage(ctx context.Context, writer io.Writer, data *TimelinePageTemplateData) (err error)
RenderThreadPage(ctx context.Context, writer io.Writer, data *ThreadPageTemplateData) (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 { 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) 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 { func WithEmojis(content string, emojis []mastodon.Emoji) string {
var emojiNameContentPair []string var emojiNameContentPair []string
for _, e := range emojis { for _, e := range emojis {

View 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) 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) { func (s *authService) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
c, err = s.getClient(ctx) c, err = s.getClient(ctx)
if err != nil { if err != nil {

View File

@ -77,6 +77,14 @@ func (s *loggingService) ServeThreadPage(ctx context.Context, client io.Writer,
return s.Service.ServeThreadPage(ctx, client, c, id, reply) 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) { func (s *loggingService) Like(ctx context.Context, client io.Writer, c *mastodon.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

@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@ -33,6 +32,7 @@ type Service interface {
ServeSigninPage(ctx context.Context, client io.Writer) (err error) 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) 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) 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) 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) 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) 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 { if len(maxID) > 0 && len(statuses) > 0 {
hasPrev = true 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 { if len(minID) > 0 && len(pg.MinID) > 0 {
newStatuses, err := c.GetTimelineHome(ctx, &mastodon.Pagination{MinID: pg.MinID, Limit: 20}) 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) newStatusesLen := len(newStatuses)
if newStatusesLen == 20 { if newStatusesLen == 20 {
hasPrev = true hasPrev = true
prevLink = fmt.Sprintf("/timeline?min_id=%s", pg.MinID) prevLink = "/timeline?min_id=" + pg.MinID
} else { } else {
i := 20 - newStatusesLen - 1 i := 20 - newStatusesLen - 1
if len(statuses) > i { if len(statuses) > i {
hasPrev = true hasPrev = true
prevLink = fmt.Sprintf("/timeline?min_id=%s", statuses[i].ID) prevLink = "/timeline?min_id=" + statuses[i].ID
} }
} }
} }
if len(pg.MaxID) > 0 { if len(pg.MaxID) > 0 {
hasNext = true 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) err = svc.renderer.RenderTimelinePage(ctx, client, data)
if err != nil { if err != nil {
return 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) err = svc.renderer.RenderThreadPage(ctx, client, data)
if err != nil { if err != nil {
return return
@ -289,6 +299,78 @@ func (svc *service) ServeThreadPage(ctx context.Context, client io.Writer, c *ma
return 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) { func (svc *service) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
_, err = c.Favourite(ctx, id) _, err = c.Favourite(ctx, id)
return return

View File

@ -179,6 +179,19 @@ func NewHandler(s Service, staticDir string) http.Handler {
w.WriteHeader(http.StatusSeeOther) w.WriteHeader(http.StatusSeeOther)
}).Methods(http.MethodPost) }).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) { r.HandleFunc("/signout", func(w http.ResponseWriter, req *http.Request) {
// TODO remove session from database // TODO remove session from database
w.Header().Add("Set-Cookie", fmt.Sprintf("session_id=;max-age=0")) w.Header().Add("Set-Cookie", fmt.Sprintf("session_id=;max-age=0"))

View File

@ -17,11 +17,10 @@
.status-profile-img { .status-profile-img {
height: 48px; height: 48px;
width: 48px; width: 48px;
object-fit: contain; margin-right: 8px;
} }
.status { .status {
margin: 0 8px;
} }
.status a { .status a {
@ -119,7 +118,6 @@
height: 24px; height: 24px;
width: 24px; width: 24px;
margin-bottom: -8px; margin-bottom: -8px;
object-fit: contain;
} }
.retweet-info .status-dname{ .retweet-info .status-dname{
@ -143,3 +141,23 @@
.pagination a { .pagination a {
margin: 0 8px; 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;
}

View File

@ -1,4 +1,5 @@
<div class="navigation"> <div class="navigation">
<a href="/timeline">home</a> <a href="/timeline">home</a>
<a href="/notifications">notifications{{if gt .NotificationCount 0}} ({{.NotificationCount}}){{end}}</a>
<a href="/signout">sign out</a> <a href="/signout">sign out</a>
</div> </div>

View File

@ -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"}}

View File

@ -11,13 +11,17 @@
{{block "status" .}} {{block "status" .}}
<div class="status-container"> <div class="status-container">
<div> <div>
{{if ne .Account.ID ""}}
<img class="status-profile-img" src="{{.Account.AvatarStatic}}" alt="profile-avatar" /> <img class="status-profile-img" src="{{.Account.AvatarStatic}}" alt="profile-avatar" />
{{end}}
</div> </div>
<div class="status"> <div class="status">
{{if ne .Account.ID ""}}
<div class="status-name"> <div class="status-name">
<span class="status-dname"> {{WithEmojis .Account.DisplayName .Account.Emojis}} </span> <span class="status-dname"> {{WithEmojis .Account.DisplayName .Account.Emojis}} </span>
<span class="status-uname"> {{.Account.Acct}} </span> <span class="status-uname"> {{.Account.Acct}} </span>
</div> </div>
{{end}}
<div class="status-content"> {{WithEmojis .Content .Emojis}} </div> <div class="status-content"> {{WithEmojis .Content .Emojis}} </div>
<div class="status-media-container"> <div class="status-media-container">
{{range .MediaAttachments}} {{range .MediaAttachments}}

View File

@ -1,6 +1,6 @@
{{template "header.tmpl"}} {{template "header.tmpl"}}
{{template "navigation.tmpl" .NavbarData}}
<div class="page-title"> Thread </div> <div class="page-title"> Thread </div>
{{template "navigation.tmpl"}}
{{range .Context.Ancestors}} {{range .Context.Ancestors}}
{{template "status.tmpl" .}} {{template "status.tmpl" .}}

View File

@ -1,6 +1,6 @@
{{template "header.tmpl"}} {{template "header.tmpl"}}
{{template "navigation.tmpl" .NavbarData}}
<div class="page-title"> Timeline </div> <div class="page-title"> Timeline </div>
{{template "navigation.tmpl"}}
<form class="timeline-post-form" action="/post" method="POST" enctype="multipart/form-data"> <form class="timeline-post-form" action="/post" method="POST" enctype="multipart/form-data">
<label for="post-content"> New Post </label> <label for="post-content"> New Post </label>