From c808cf236bf4c827e37335383c361a41bf4dd1b4 Mon Sep 17 00:00:00 2001 From: Nick Gerakines Date: Sat, 25 Apr 2020 18:41:44 -0400 Subject: [PATCH] Added support for liking notes in the ui. Also implements undo/like activity in user inbox. --- storage/objects.go | 11 +- templates/objects.html | 13 +++ templates/partials/view_object.html | 12 ++- web/command.go | 1 + web/handler_compose.go | 157 ++++++++++++++++++++++++++++ web/handler_feed.go | 18 +++- web/handler_user_inbox.go | 4 +- web/view_object.go | 2 + 8 files changed, 214 insertions(+), 4 deletions(-) diff --git a/storage/objects.go b/storage/objects.go index 14a29a8..6e98a59 100644 --- a/storage/objects.go +++ b/storage/objects.go @@ -53,7 +53,7 @@ type ObjectStorage interface { CountObjectBoostsByObjectIDs(ctx context.Context, objectIDs []uuid.UUID) ([]Count, error) CountObjectBoostsByActorObjectIDs(ctx context.Context, actorRowID uuid.UUID, objectRowIDs []uuid.UUID) ([]Count, error) CountObjectRepliesByObjectIDs(ctx context.Context, objectIDs []uuid.UUID) ([]Count, error) - + CountObjectLikesByObjectIDs(ctx context.Context, objectIDs []uuid.UUID) ([]Count, error) ExistsObjectInUserFeedByObjectID(ctx context.Context, objectID string) (bool, error) ObjectParentsByObjectID(ctx context.Context, objectRowID uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) ObjectChildrenByObjectID(ctx context.Context, objectRowID uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) @@ -306,6 +306,15 @@ func (s pgStorage) CountObjectBoostsByObjectIDs(ctx context.Context, objectIDs [ return s.keyedCount(errors.WrapObjectBoostQueryFailedError, ctx, query, common.UUIDsToInterfaces(objectIDs)...) } +func (s pgStorage) CountObjectLikesByObjectIDs(ctx context.Context, objectIDs []uuid.UUID) ([]Count, error) { + if len(objectIDs) == 0 { + return []Count{}, nil + } + placeholders := common.DollarForEach(len(objectIDs)) + query := fmt.Sprintf(`SELECT object_id, COUNT(*) FROM object_likes WHERE object_id IN (%s) GROUP BY object_id`, strings.Join(placeholders, ",")) + return s.keyedCount(errors.WrapObjectBoostQueryFailedError, ctx, query, common.UUIDsToInterfaces(objectIDs)...) +} + func (s pgStorage) CountObjectRepliesByObjectIDs(ctx context.Context, objectIDs []uuid.UUID) ([]Count, error) { if len(objectIDs) == 0 { return []Count{}, nil diff --git a/templates/objects.html b/templates/objects.html index f03251f..f1c00e4 100644 --- a/templates/objects.html +++ b/templates/objects.html @@ -14,6 +14,19 @@ })); newForm.hide().appendTo("body").submit(); }); + + $('body').on('click', 'a.like', function () { + let newForm = jQuery('
', { + 'action': '/dashboard/notes/like/note', + 'method': 'post', + 'target': '_top' + }).append(jQuery('', { + 'name': 'object', + 'value': $(this).data('object'), + 'type': 'hidden' + })); + newForm.hide().appendTo("body").submit(); + }); }); diff --git a/templates/partials/view_object.html b/templates/partials/view_object.html index 9bd2e88..6ffa74c 100644 --- a/templates/partials/view_object.html +++ b/templates/partials/view_object.html @@ -35,6 +35,13 @@ Announce {{ end }} + {{ if .ViewContext.LinkLike }} + + {{ end }} {{ if and (or .ViewContext.LinkView .ViewContext.LinkReply) .ViewContext.LinkDelete }} {{ end }} {{ if .ViewContext.LinkDelete }} @@ -104,7 +111,7 @@ {{ end }} {{ end }} - {{ if or .ViewContext.TotalAnnounces .ViewContext.TotalReplies .ViewContext.ViewerAnnounced }} + {{ if or .ViewContext.TotalAnnounces .ViewContext.TotalReplies .ViewContext.TotalLikes .ViewContext.ViewerAnnounced }} {{ end }} diff --git a/web/command.go b/web/command.go index 6a1f55f..0cb49c3 100644 --- a/web/command.go +++ b/web/command.go @@ -406,6 +406,7 @@ func serverCommandAction(cliCtx *cli.Context) error { authenticated.GET("/compose", h.compose) authenticated.POST("/compose/create/note", h.createNote) authenticated.POST("/dashboard/notes/announce/note", h.announceNote) + authenticated.POST("/dashboard/notes/like/note", h.likeNote) authenticated.POST("/notes/delete", h.deleteNote) authenticated.GET("/configure", h.configure) diff --git a/web/handler_compose.go b/web/handler_compose.go index f63dea1..364c02c 100644 --- a/web/handler_compose.go +++ b/web/handler_compose.go @@ -613,6 +613,163 @@ func (h handler) announceNote(c *gin.Context) { c.Redirect(http.StatusFound, h.url("feed_mine")) } +func (h handler) likeNote(c *gin.Context) { + session := sessions.Default(c) + ctx := c.Request.Context() + + user, err := h.storage.GetUserBySession(ctx, session) + if err != nil { + if errors.Is(err, errors.UserSessionNotFoundError{}) { + + if err = appendFlashError(session, "Not Authenticated"); err != nil { + h.hardFail(c, errors.NewCannotSaveSessionError(err)) + return + } + + c.Redirect(http.StatusFound, h.url()) + return + } + h.hardFail(c, err) + return + } + + userActor, err := h.storage.GetActor(ctx, user.ActorID) + if err != nil { + h.hardFail(c, err) + return + } + userActorFollowers, _ := storage.JSONString(userActor.Payload, "followers") + + objectID := c.PostForm("object") + if len(objectID) == 0 { + h.flashErrorOrFail(c, h.url("feed_recent"), fmt.Errorf("invalid object id")) + return + } + + objectPayload, err := h.storage.ObjectPayloadByObjectID(ctx, objectID) + if err != nil { + h.hardFail(c, err) + return + } + + attributedTo, hasAttributedTo := storage.JSONString(objectPayload, "attributedTo") + if !hasAttributedTo { + h.hardFail(c, fmt.Errorf("object has no attributedTo attribute"), zap.String("object_id", objectID)) + return + } + + if userActor.ActorID == attributedTo { + h.hardFail(c, fmt.Errorf("you cannot like your own stuff"), zap.String("object_id", objectID)) + return + } + + sourceActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, h.httpClient, attributedTo) + if err != nil { + h.hardFail(c, err, zap.String("actor", attributedTo)) + return + } + + sourceActorInbox, hasSourceActorInbox := storage.JSONString(sourceActor.Payload, "inbox") + if !hasSourceActorInbox { + h.hardFail(c, fmt.Errorf("actor has no inbox attribute"), zap.String("actor", attributedTo)) + return + } + + now := time.Now().UTC() + publishedAt := now.Format("2006-01-02T15:04:05Z") + + to := []string{ + "https://www.w3.org/ns/activitystreams#Public", + attributedTo, + } + cc := []string{ + userActorFollowers, + } + + likeURL := common.ActivityURL(h.domain, storage.NewV4()) + like := storage.EmptyPayload() + like["@context"] = "https://www.w3.org/ns/activitystreams" + like["id"] = likeURL + like["type"] = "Like" + like["actor"] = userActor.ActorID + like["published"] = publishedAt + like["to"] = to + like["cc"] = cc + like["object"] = objectID + likePayload := like.Bytes() + + var destinations []string + + err = storage.TransactionalStorage(ctx, h.storage, func(tx storage.Storage) error { + objectRowID, err := tx.ObjectRowIDForObjectID(ctx, objectID) + if err != nil { + return err + } + + activityRowID, err := tx.RecordObjectEvent(ctx, likeURL, objectRowID, like) + if err != nil { + return err + } + + _, err = tx.RecordUserObjectEvent(ctx, user.ID, activityRowID, objectRowID, true) + if err != nil { + return err + } + _, err = tx.RecordLike(ctx, userActor.ID, objectRowID, like) + if err != nil { + return err + } + + followerTotal, err := tx.CountFollowers(ctx, user.ID) + if err != nil { + return err + } + + destinations, err = tx.ListAcceptedFollowers(ctx, user.ID, followerTotal, 0) + if err != nil { + return err + } + + return nil + }) + if err != nil { + h.internalServerErrorJSON(c, err) + return + } + + destinations = append(destinations, sourceActorInbox) + destinations = uniqueTo(destinations, userActor.ActorID) + + nc := fed.ActorClient{ + HTTPClient: h.httpClient, + Logger: h.logger, + } + localActor := h.userActor(user, userActor) + + for _, dest := range destinations { + foundActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, h.httpClient, dest) + if err != nil { + h.logger.Error("unable to get or fetch actor", zap.Error(err), zap.String("actor", dest)) + continue + } + + if h.publisherClient != nil { + err = h.publisherClient.Send(context.Background(), foundActor.GetInbox(), userActor.GetKeyID(), user.PrivateKey, likeURL, string(likePayload)) + if err != nil { + h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", likeURL), zap.Error(err)) + } + } else { + err = nc.SendToInbox(ctx, localActor, foundActor, likePayload) + if err != nil { + h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", likeURL)) + } + } + + } + + c.Redirect(http.StatusFound, h.url("feed_mine")) +} + func uniqueTo(input []string, exclude ...string) []string { results := make([]string, 0) diff --git a/web/handler_feed.go b/web/handler_feed.go index 1e788e1..0f71baf 100644 --- a/web/handler_feed.go +++ b/web/handler_feed.go @@ -152,6 +152,16 @@ func (h handler) displayObjectFeed(c *gin.Context, requireUser bool, vars map[st mappedBoosts[boost.Key] = boost.Count } + likes, err := h.storage.CountObjectLikesByObjectIDs(ctx, collectedRowIDs) + if err != nil { + h.hardFail(c, err) + return + } + mappedLikes := make(map[string]int) + for _, like := range likes { + mappedLikes[like.Key] = like.Count + } + mappedUserBoosts := make(map[string]int) if localUserActor != nil { userBoosts, err := h.storage.CountObjectBoostsByActorObjectIDs(ctx, localUserActor.ID, collectedRowIDs) @@ -308,7 +318,10 @@ func (h handler) displayObjectFeed(c *gin.Context, requireUser bool, vars map[st if viewContext.LinkReply { viewContext.LinkReplyURL = fmt.Sprintf("%s?inReplyTo=%s", h.url("compose"), url.QueryEscape(objectID)) } - viewContext.LinkAnnounce = true + if localUserActor != nil && attributedTo != localUserActor.ActorID { + viewContext.LinkAnnounce = true + viewContext.LinkLike = true + } if localUserActor != nil && attributedTo == localUserActor.ActorID { viewContext.LinkDelete = true @@ -397,6 +410,9 @@ func (h handler) displayObjectFeed(c *gin.Context, requireUser bool, vars map[st if count, hasCount := mappedBoosts[viewObjectRowID.String()]; hasCount { viewContext.TotalAnnounces = count } + if count, hasCount := mappedLikes[viewObjectRowID.String()]; hasCount { + viewContext.TotalLikes = count + } if count, hasCount := mappedReplies[viewObjectRowID.String()]; hasCount { viewContext.TotalReplies = count } diff --git a/web/handler_user_inbox.go b/web/handler_user_inbox.go index f7dda3f..9a4ff50 100644 --- a/web/handler_user_inbox.go +++ b/web/handler_user_inbox.go @@ -104,7 +104,7 @@ func (h handler) actorInboxLike(c *gin.Context, user *storage.User, payload stor } actor, hasActor := storage.JSONString(payload, "actor") - object, hasObject := storage.JSONDeepString(payload, "object", "object") + object, hasObject := storage.JSONString(payload, "object") if !hasActor || !hasObject { h.logger.Warn("unable to process like activity", @@ -593,6 +593,8 @@ func (h handler) actorInboxUndo(c *gin.Context, user *storage.User, payload stor switch innerType { case "Follow": h.actorInboxUndoFollow(c, user, payload) + case "Like": + h.actorInboxUndoLike(c, user, payload) default: h.logger.Warn("User received unexpected undo payload type", zap.String("type", innerType), zap.String("user", user.Name)) c.Status(http.StatusOK) diff --git a/web/view_object.go b/web/view_object.go index 09b8b97..e88c84c 100644 --- a/web/view_object.go +++ b/web/view_object.go @@ -50,6 +50,7 @@ type viewObjectContext struct { LinkReplyURL string LinkAnnounce bool + LinkLike bool LinkDelete bool AttributedTo string @@ -81,6 +82,7 @@ type viewObjectContext struct { ViewerAnnounced bool TotalAnnounces int TotalReplies int + TotalLikes int Error string