diff --git a/.rubocop.yml b/.rubocop.yml index e3306ee3d..0f69beccf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -73,6 +73,11 @@ Layout/HashAlignment: EnforcedHashRocketStyle: table EnforcedColonStyle: table +# This rule makes haml files less readable, as there is no 'end' there. +Layout/CaseIndentation: + Exclude: + - "app/views/**/*" + # Mixing the styles looks just silly. Style/HashSyntax: EnforcedStyle: ruby19_no_mixed_keys diff --git a/Changelog.md b/Changelog.md index 69412a734..65cd65ae0 100644 --- a/Changelog.md +++ b/Changelog.md @@ -81,6 +81,7 @@ We recommend setting up new pods using Ruby 3.1, and updating existing pods to t * Tell users that there is no help in mobile version, allow to switch to desktop [#8407](https://github.com/diaspora/diaspora/pull/8407) * Add Smart App Banner on iOS devices [#8409](https://github.com/diaspora/diaspora/pull/8409) * Add a more detailed modal when reporting a post or a comment [#8035](https://github.com/diaspora/diaspora/pull/8035) +* Re-introduce likes on comments [#8203](https://github.com/diaspora/diaspora/pull/8203) # 0.7.18.2 diff --git a/app/assets/javascripts/app/collections/comments.js b/app/assets/javascripts/app/collections/comments.js index 9ecf4f047..52f98d181 100644 --- a/app/assets/javascripts/app/collections/comments.js +++ b/app/assets/javascripts/app/collections/comments.js @@ -12,12 +12,17 @@ app.collections.Comments = Backbone.Collection.extend({ make : function(text) { var self = this; - var comment = new app.models.Comment({ "text": text }); + var comment = new app.models.Comment({"text": text}, {post: this.post}); var deferred = comment.save({}, { url: "/posts/"+ this.post.id +"/comments", success: function() { comment.set({author: app.currentUser.toJSON(), parent: self.post }); + + // Need interactions after make + comment.interactions = new app.models.LikeInteractions( + _.extend({comment: comment, post: self.post}, comment.get("interactions")) + ); self.add(comment); } }); diff --git a/app/assets/javascripts/app/collections/likes.js b/app/assets/javascripts/app/collections/likes.js index 76168237b..7f42f9eaf 100644 --- a/app/assets/javascripts/app/collections/likes.js +++ b/app/assets/javascripts/app/collections/likes.js @@ -4,7 +4,11 @@ app.collections.Likes = Backbone.Collection.extend({ model: app.models.Like, initialize : function(models, options) { - this.url = "/posts/" + options.post.id + "/likes"; //not delegating to post.url() because when it is in a stream collection it delegates to that url + // A comment- like has a post reference and a comment reference + this.url = (options.comment != null) ? + // not delegating to post.url() because when it is in a stream collection it delegates to that url + "/comments/" + options.comment.id + "/likes" : + "/posts/" + options.post.id + "/likes"; } }); // @license-end diff --git a/app/assets/javascripts/app/models/comment.js b/app/assets/javascripts/app/models/comment.js index d382b731f..5b4b19598 100644 --- a/app/assets/javascripts/app/models/comment.js +++ b/app/assets/javascripts/app/models/comment.js @@ -1,6 +1,17 @@ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later app.models.Comment = Backbone.Model.extend({ - urlRoot: "/comments" + urlRoot: "/comments", + + initialize: function(model, options) { + options = options || {}; + this.post = model.post || options.post || this.collection.post; + this.interactions = new app.models.LikeInteractions( + _.extend({comment: this, post: this.post}, this.get("interactions")) + ); + this.likes = this.interactions.likes; + this.likesCount = this.attributes.likes_count; + this.userLike = this.interactions.userLike(); + } }); // @license-end diff --git a/app/assets/javascripts/app/models/like_interactions.js b/app/assets/javascripts/app/models/like_interactions.js new file mode 100644 index 000000000..4642d663b --- /dev/null +++ b/app/assets/javascripts/app/models/like_interactions.js @@ -0,0 +1,58 @@ +// This class contains code extracted from interactions.js to factorize likes management between posts and comments + +app.models.LikeInteractions = Backbone.Model.extend({ + + initialize: function(options) { + this.likes = new app.collections.Likes(this.get("likes"), options); + this.post = options.post; + }, + + likesCount: function() { + return this.get("likes_count"); + }, + + userLike: function() { + return this.likes.select(function(like) { + return like.get("author") && like.get("author").guid === app.currentUser.get("guid"); + })[0]; + }, + + toggleLike: function() { + if (this.userLike()) { + this.unlike(); + } else { + this.like(); + } + }, + + like: function() { + var self = this; + this.likes.create({}, { + success: function() { + self.post.set({participation: true}); + self.trigger("change"); + self.set({"likes_count": self.get("likes_count") + 1}); + self.likes.trigger("change"); + }, + error: function(model, response) { + app.flashMessages.handleAjaxError(response); + } + }); + }, + + unlike: function() { + var self = this; + this.userLike().destroy({ + success: function() { + // TODO: unlike always sets participation to false in the UI, even if there are more participations left + // in the backend (from manually participating, other likes or comments) + self.post.set({participation: false}); + self.trigger("change"); + self.set({"likes_count": self.get("likes_count") - 1}); + self.likes.trigger("change"); + }, + error: function(model, response) { + app.flashMessages.handleAjaxError(response); + }}); + } +}); diff --git a/app/assets/javascripts/app/models/post.js b/app/assets/javascripts/app/models/post.js index b693d7b2d..3289fcc57 100644 --- a/app/assets/javascripts/app/models/post.js +++ b/app/assets/javascripts/app/models/post.js @@ -4,7 +4,7 @@ app.models.Post = Backbone.Model.extend(_.extend({}, app.models.formatDateMixin, urlRoot : "/posts", initialize : function() { - this.interactions = new app.models.Post.Interactions(_.extend({post : this}, this.get("interactions"))); + this.interactions = new app.models.PostInteractions(_.extend({post: this}, this.get("interactions"))); this.delegateToInteractions(); }, diff --git a/app/assets/javascripts/app/models/post/interactions.js b/app/assets/javascripts/app/models/post_interactions.js similarity index 61% rename from app/assets/javascripts/app/models/post/interactions.js rename to app/assets/javascripts/app/models/post_interactions.js index 572375c73..8380ba974 100644 --- a/app/assets/javascripts/app/models/post/interactions.js +++ b/app/assets/javascripts/app/models/post_interactions.js @@ -1,75 +1,27 @@ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later -//require ../post - -app.models.Post.Interactions = Backbone.Model.extend({ - initialize : function(options){ +app.models.PostInteractions = app.models.LikeInteractions.extend({ + initialize: function(options) { + app.models.LikeInteractions.prototype.initialize.apply(this, arguments); this.post = options.post; - this.comments = new app.collections.Comments(this.get("comments"), {post : this.post}); - this.likes = new app.collections.Likes(this.get("likes"), {post : this.post}); - this.reshares = new app.collections.Reshares(this.get("reshares"), {post : this.post}); + this.comments = new app.collections.Comments(this.get("comments"), {post: this.post}); + this.reshares = new app.collections.Reshares(this.get("reshares"), {post: this.post}); }, - likesCount : function(){ - return this.get("likes_count"); - }, - - resharesCount : function(){ + resharesCount: function() { return this.get("reshares_count"); }, - commentsCount : function(){ + commentsCount: function() { return this.get("comments_count"); }, - userLike : function(){ - return this.likes.select(function(like){ - return like.get("author") && like.get("author").guid === app.currentUser.get("guid"); - })[0]; - }, - - userReshare : function(){ + userReshare: function() { return this.reshares.select(function(reshare){ return reshare.get("author") && reshare.get("author").guid === app.currentUser.get("guid"); })[0]; }, - toggleLike : function() { - if(this.userLike()) { - this.unlike(); - } else { - this.like(); - } - }, - - like : function() { - var self = this; - this.likes.create({}, { - success: function() { - self.post.set({participation: true}); - self.trigger("change"); - self.set({"likes_count" : self.get("likes_count") + 1}); - self.likes.trigger("change"); - }, - error: function(model, response) { - app.flashMessages.handleAjaxError(response); - } - }); - }, - - unlike : function() { - var self = this; - this.userLike().destroy({success : function() { - self.post.set({participation: false}); - self.trigger('change'); - self.set({"likes_count" : self.get("likes_count") - 1}); - self.likes.trigger("change"); - }, - error: function(model, response) { - app.flashMessages.handleAjaxError(response); - }}); - }, - comment: function(text, options) { var self = this; options = options || {}; @@ -109,7 +61,7 @@ app.models.Post.Interactions = Backbone.Model.extend({ }); }, - userCanReshare : function(){ + userCanReshare: function() { var isReshare = this.post.get("post_type") === "Reshare" , rootExists = (isReshare ? this.post.get("root") : true) , publicPost = this.post.get("public") diff --git a/app/assets/javascripts/app/views/comment_view.js b/app/assets/javascripts/app/views/comment_view.js index a75150290..f02258d60 100644 --- a/app/assets/javascripts/app/views/comment_view.js +++ b/app/assets/javascripts/app/views/comment_view.js @@ -6,35 +6,52 @@ app.views.Comment = app.views.Content.extend({ className : "comment media", tooltipSelector: "time", - events : function() { + subviews: { + ".likes-on-comment": "likesInfoView" + }, + + events: function() { return _.extend({}, app.views.Content.prototype.events, { "click .comment_delete": "destroyModel", - "click .comment_report": "report" + "click .comment_report": "report", + "click .like": "toggleLike" }); }, - initialize : function(options){ + initialize: function(options) { this.templateName = options.templateName || this.templateName; + this.model.interactions.on("change", this.render, this); this.model.on("change", this.render, this); }, - presenter : function() { + presenter: function() { return _.extend(this.defaultPresenter(), { canRemove: this.canRemove(), - text: app.helpers.textFormatter(this.model.get("text"), this.model.get("mentioned_people")) + text: app.helpers.textFormatter(this.model.get("text"), this.model.get("mentioned_people")), + likesCount: this.model.attributes.likesCount, + userLike: this.model.interactions.userLike() }); }, - ownComment : function() { + ownComment: function() { return app.currentUser.authenticated() && this.model.get("author").diaspora_id === app.currentUser.get("diaspora_id"); }, - postOwner : function() { + postOwner: function() { return app.currentUser.authenticated() && this.model.get("parent").author.diaspora_id === app.currentUser.get("diaspora_id"); }, - canRemove : function() { + canRemove: function() { return app.currentUser.authenticated() && (this.ownComment() || this.postOwner()); + }, + + toggleLike: function(evt) { + if (evt) { evt.preventDefault(); } + this.model.interactions.toggleLike(); + }, + + likesInfoView: function() { + return new app.views.LikesInfo({model: this.model}); } }); diff --git a/app/assets/javascripts/app/views/stream_post_views.js b/app/assets/javascripts/app/views/stream_post_views.js index c454462ac..9843874d2 100644 --- a/app/assets/javascripts/app/views/stream_post_views.js +++ b/app/assets/javascripts/app/views/stream_post_views.js @@ -7,7 +7,7 @@ app.views.StreamPost = app.views.Post.extend({ subviews : { ".feedback": "feedbackView", ".comments": "commentStreamView", - ".likes": "likesInfoView", + ".likes-on-post": "likesInfoView", ".reshares": "resharesInfoView", ".post-controls": "postControlsView", ".post-content": "postContentView", diff --git a/app/assets/javascripts/mobile/mobile_comments.js b/app/assets/javascripts/mobile/mobile_comments.js index 270799f7b..6eaf9ca61 100644 --- a/app/assets/javascripts/mobile/mobile_comments.js +++ b/app/assets/javascripts/mobile/mobile_comments.js @@ -52,6 +52,8 @@ $.post(form.attr("action") + "?format=mobile", form.serialize(), function(data){ Diaspora.Mobile.Comments.updateStream(form, data); + // Register new comments + $(".stream").trigger("comments.loaded"); }, "html").fail(function(response) { Diaspora.Mobile.Alert.handleAjaxError(response); Diaspora.Mobile.Comments.resetCommentBox(form); @@ -107,10 +109,12 @@ url: toggleReactionsLink.attr("href"), success: function (data) { toggleReactionsLink.addClass("active").removeClass("loading"); - $(data).insertAfter(bottomBar.children(".show-comments").first()); + $(data).insertAfter(bottomBar.children(".post-actions-container").first()); self.showCommentBox(commentActionLink); bottomBarContainer.getCommentsContainer().find("time.timeago").timeago(); bottomBarContainer.activate(); + // Inform the comment action for new comments + $(".stream").trigger("comments.loaded"); }, error: function(){ bottomBarContainer.deactivate(); diff --git a/app/assets/javascripts/mobile/mobile_post_actions.js b/app/assets/javascripts/mobile/mobile_post_actions.js index 76f1126d5..6698e6641 100644 --- a/app/assets/javascripts/mobile/mobile_post_actions.js +++ b/app/assets/javascripts/mobile/mobile_post_actions.js @@ -3,6 +3,11 @@ initialize: function() { $(".like-action", ".stream").bind("tap click", this.onLike); $(".reshare-action", ".stream").bind("tap click", this.onReshare); + // Add handler to newly loaded comments + var self = this; + $(".stream").bind("comments.loaded", function() { + $(".like-action", ".stream").bind("tap click", self.onLike); + }); }, showLoader: function(link) { @@ -75,8 +80,8 @@ onLike: function(evt){ evt.preventDefault(); - var link = $(evt.target).closest(".like-action"), - likeCounter = $(evt.target).closest(".stream-element").find(".like-count"); + var link = $(evt.target).closest(".like-action").first(), + likeCounter = $(evt.target).find(".like-count").first(); if(!link.hasClass("loading") && link.hasClass("inactive")) { Diaspora.Mobile.PostActions.like(likeCounter, link); diff --git a/app/assets/stylesheets/comments.scss b/app/assets/stylesheets/comments.scss index 88d4b16b5..f47e0bae4 100644 --- a/app/assets/stylesheets/comments.scss +++ b/app/assets/stylesheets/comments.scss @@ -24,13 +24,9 @@ .comments > .comment, .comment.new-comment-form-wrapper { - .avatar { - height: 35px; - width: 35px; - } margin: 0; border-top: 1px dotted $border-grey; - padding: 10px 0; + padding: 10px 0 0; .info { margin-top: 5px; @@ -57,8 +53,6 @@ } } - .comment.new-comment-form-wrapper { padding-bottom: 0; } - .submit-button { margin-top: 10px; input { @@ -84,6 +78,25 @@ } } +.likes-on-comment { + &.likes { + margin-top: 6px; + } + + .media { + margin: 0 0 2px; + + &:not(.display-avatars) .entypo-heart { + display: none; + } + } + + .expand-likes { + display: inline-block; + margin-bottom: 4px; + } +} + .new-comment { &:not(.open) .submit-button, &:not(.open) .md-header { diff --git a/app/assets/stylesheets/mobile/comments.scss b/app/assets/stylesheets/mobile/comments.scss index 62db27354..feed33d84 100644 --- a/app/assets/stylesheets/mobile/comments.scss +++ b/app/assets/stylesheets/mobile/comments.scss @@ -1,8 +1,5 @@ .bottom-bar { - border-radius: 0 0 5px 5px; z-index: 3; - display: block; - position: relative; padding: 8px 10px 10px; background: $background-grey; margin-top: 10px; @@ -10,6 +7,17 @@ min-height: 22px; overflow: hidden; + &, + .comment-stats { + border-bottom-left-radius: $border-radius-small; + border-bottom-right-radius: $border-radius-small; + } + + .post-actions-container { + display: flex; + justify-content: space-between; + } + > a, .show-comments, .show-comments > [class^="entypo"] { @@ -37,8 +45,7 @@ } } - .post-stats { - float: right; + %stats { position: relative; display: flex; @@ -46,9 +53,9 @@ color: $text-color; font-family: $font-family-base; font-size: $font-size-base; - line-height: 22px; + line-height: 24px; margin-left: 5px; - vertical-align: top; + vertical-align: text-bottom; z-index: 2; } @@ -67,17 +74,36 @@ } .entypo-reshare.active { color: $blue; } - .entypo-heart.active { color: $red; } } - .post-action { + .post-stats { + @extend %stats; + } + + .comment-stats { + @extend %stats; + background: $background-grey; + border-top: 1px solid $border-grey; + flex-direction: row-reverse; + padding: 3px; + } + + %action { display: flex; margin: 0 7px; .disabled { color: $medium-gray; } } + .post-action { + @extend %action; + } + + .comment-action { + @extend %action; + } + .add-comment-switcher { padding-top: 10px; } &.inactive { @@ -91,16 +117,19 @@ .stream-element .comments { margin: 0; - margin-top: 10px; padding: 0; width: 100%; .content { padding: 0; } .comment { - border-top: 1px solid $border-medium-grey; - padding: 10px 0 0; + background-color: $framed-background; + border: 1px solid $border-medium-grey; + border-radius: 5px; + margin-top: 10px; - &:first-child { padding-top: 20px; } + .media { + padding: 6px; + } } } diff --git a/app/assets/stylesheets/mobile/markdown_editor.scss b/app/assets/stylesheets/mobile/markdown_editor.scss index b39f5a0ba..2cc314d1e 100644 --- a/app/assets/stylesheets/mobile/markdown_editor.scss +++ b/app/assets/stylesheets/mobile/markdown_editor.scss @@ -1,5 +1,5 @@ .md-editor { - border: 1px solid $light-grey; + border: 1px solid $border-medium-grey; border-radius: $btn-border-radius-base; &.active { border-color: $text-grey; } diff --git a/app/assets/stylesheets/mobile/mobile.scss b/app/assets/stylesheets/mobile/mobile.scss index 2a88055e6..7ee53069e 100644 --- a/app/assets/stylesheets/mobile/mobile.scss +++ b/app/assets/stylesheets/mobile/mobile.scss @@ -516,9 +516,13 @@ h3.ltr { font-style: inherit; font-weight: inherit; margin: 0; - padding: 15px 15px; + padding: 12px; vertical-align: baseline; word-wrap: break-word; + + p:last-child { + margin: 0; + } } form#new_conversation.new_conversation { diff --git a/app/assets/stylesheets/single-post-view.scss b/app/assets/stylesheets/single-post-view.scss index ceaabc4fa..843cc33f0 100644 --- a/app/assets/stylesheets/single-post-view.scss +++ b/app/assets/stylesheets/single-post-view.scss @@ -116,9 +116,6 @@ .no-comments { text-align: center; } - a { - color: $link-color; - } .count { float: left; i { @@ -144,6 +141,7 @@ .comment.new-comment-form-wrapper { padding: 10px; } + .comments > .comment { padding-bottom: 0; } .count, .interaction-avatars { @@ -164,4 +162,19 @@ width: $line-height-computed; } } + .likes { + font-size: 12px; + line-height: 16px; + + .bd { display: inline-block; } + img { display: inline; } + } + + .display-avatars .entypo-heart { + display: inline-block; + font-size: 16px; + line-height: 18px; + margin-right: 5px; + vertical-align: top; + } } diff --git a/app/assets/stylesheets/stream_element.scss b/app/assets/stylesheets/stream_element.scss index 6f876ba15..24e1c5bcf 100644 --- a/app/assets/stylesheets/stream_element.scss +++ b/app/assets/stylesheets/stream_element.scss @@ -191,6 +191,16 @@ } } + .comments { + .likes { + line-height: 10px; + } + + .expand-likes { + line-height: 20px; + } + } + .status-message-location { color: $text-grey; font-size: $font-size-small; diff --git a/app/assets/templates/comment_tpl.jst.hbs b/app/assets/templates/comment_tpl.jst.hbs index 36d56ef1c..f515e0c45 100644 --- a/app/assets/templates/comment_tpl.jst.hbs +++ b/app/assets/templates/comment_tpl.jst.hbs @@ -36,5 +36,19 @@
{{{text}}}
+ + {{#if loggedIn}} +
+ + {{~#if userLike~}} + {{~t "stream.unlike"~}} + {{~else~}} + {{~t "stream.like"~}} + {{~/if~}} + +
+ {{/if}} +
+ diff --git a/app/assets/templates/likes-info_tpl.jst.hbs b/app/assets/templates/likes-info_tpl.jst.hbs index ff5346657..4aa193b6a 100644 --- a/app/assets/templates/likes-info_tpl.jst.hbs +++ b/app/assets/templates/likes-info_tpl.jst.hbs @@ -1,6 +1,6 @@ {{#if likesCount}}
-
+
@@ -8,9 +8,7 @@ {{t "stream.likes" count=likesCount}} - {{else}} - {{#each likes}} {{#linkToAuthor author}} {{{personImage this 'small' 'micro'}}} diff --git a/app/assets/templates/stream-element_tpl.jst.hbs b/app/assets/templates/stream-element_tpl.jst.hbs index cb860b63a..27cd3aedb 100644 --- a/app/assets/templates/stream-element_tpl.jst.hbs +++ b/app/assets/templates/stream-element_tpl.jst.hbs @@ -37,7 +37,7 @@ {{#unless preview}} - +
{{/unless}} diff --git a/app/controllers/api/v1/likes_controller.rb b/app/controllers/api/v1/likes_controller.rb index d2d052f7f..4e0103bd3 100644 --- a/app/controllers/api/v1/likes_controller.rb +++ b/app/controllers/api/v1/likes_controller.rb @@ -33,7 +33,7 @@ module Api post = post_service.find!(params.require(:post_id)) raise ActiveRecord::RecordInvalid unless post.public? || private_read? - like_service.create(params[:post_id]) + like_service.create_for_post(params[:post_id]) rescue ActiveRecord::RecordInvalid => e if e.message == "Validation failed: Target has already been taken" return render_error 409, "Like already exists" diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 647d42e35..a6767e400 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -17,26 +17,11 @@ class CommentsController < ApplicationController authenticate_user! end - def create - begin - comment = comment_service.create(params[:post_id], params[:text]) - rescue ActiveRecord::RecordNotFound - render plain: I18n.t("comments.create.error"), status: 404 - return - end - - if comment - respond_create_success(comment) - else - render plain: I18n.t("comments.create.error"), status: 422 - end - end - - def destroy - if comment_service.destroy(params[:id]) - respond_destroy_success - else - respond_destroy_error + def index + comments = comment_service.find_for_post(params[:post_id]) + respond_with do |format| + format.json { render json: CommentPresenter.as_collection(comments, :as_json, current_user), status: :ok } + format.mobile { render layout: false, locals: {comments: comments} } end end @@ -46,11 +31,26 @@ class CommentsController < ApplicationController end end - def index - comments = comment_service.find_for_post(params[:post_id]) - respond_with do |format| - format.json { render json: CommentPresenter.as_collection(comments), status: 200 } - format.mobile { render layout: false, locals: {comments: comments} } + def create + begin + comment = comment_service.create(params[:post_id], params[:text]) + rescue ActiveRecord::RecordNotFound + render plain: I18n.t("comments.create.error"), status: :not_found + return + end + + if comment + respond_create_success(comment) + else + render plain: I18n.t("comments.create.error"), status: :unprocessable_entity + end + end + + def destroy + if comment_service.destroy(params[:id]) + respond_destroy_success + else + respond_destroy_error end end diff --git a/app/controllers/likes_controller.rb b/app/controllers/likes_controller.rb index bdc43809f..112ef0c58 100644 --- a/app/controllers/likes_controller.rb +++ b/app/controllers/likes_controller.rb @@ -16,8 +16,23 @@ class LikesController < ApplicationController authenticate_user! end + def index + like = if params[:post_id] + like_service.find_for_post(params[:post_id]) + else + like_service.find_for_comment(params[:comment_id]) + end + render json: like + .includes(author: :profile) + .as_api_response(:backbone) + end + def create - like = like_service.create(params[:post_id]) + like = if params[:post_id] + like_service.create_for_post(params[:post_id]) + else + like_service.create_for_comment(params[:comment_id]) + end rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid render plain: I18n.t("likes.create.error"), status: 422 else @@ -36,12 +51,6 @@ class LikesController < ApplicationController end end - def index - render json: like_service.find_for_post(params[:post_id]) - .includes(author: :profile) - .as_api_response(:backbone) - end - private def like_service diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 31fb8aa38..120d5dcd6 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -7,24 +7,9 @@ class NotificationsController < ApplicationController before_action :authenticate_user! - def update - note = Notification.where(:recipient_id => current_user.id, :id => params[:id]).first - if note - note.set_read_state(params[:set_unread] != "true" ) - - respond_to do |format| - format.json { render :json => { :guid => note.id, :unread => note.unread } } - end - - else - respond_to do |format| - format.json { render :json => {}.to_json } - end - end - end - def index - conditions = {:recipient_id => current_user.id} + conditions = {recipient_id: current_user.id} + types = NotificationService::NOTIFICATIONS_JSON_TYPES if params[:type] && types.has_key?(params[:type]) conditions[:type] = types[params[:type]] end @@ -33,7 +18,7 @@ class NotificationsController < ApplicationController per_page = params[:per_page] || 25 @notifications = WillPaginate::Collection.create(page, per_page, Notification.where(conditions).count ) do |pager| result = Notification.where(conditions) - .includes(:target, :actors => :profile) + .includes(:target, actors: :profile) .order("updated_at desc") .limit(pager.per_page) .offset(pager.offset) @@ -52,13 +37,28 @@ class NotificationsController < ApplicationController respond_to do |format| format.html - format.xml { render :xml => @notifications.to_xml } + format.xml { render xml: @notifications.to_xml } format.json { render json: render_as_json(@unread_notification_count, @grouped_unread_notification_counts, @notifications) } end end + def update + note = Notification.where(recipient_id: current_user.id, id: params[:id]).first + if note + note.set_read_state(params[:set_unread] != "true") + + respond_to do |format| + format.json { render json: {guid: note.id, unread: note.unread} } + end + else + respond_to do |format| + format.json { render json: {}.to_json } + end + end + end + def default_serializer_options { context: self, @@ -67,7 +67,7 @@ class NotificationsController < ApplicationController end def read_all - current_type = types[params[:type]] + current_type = NotificationService::NOTIFICATIONS_JSON_TYPES[params[:type]] notifications = Notification.where(recipient_id: current_user.id, unread: true) notifications = notifications.where(type: current_type) if params[:type] notifications.update_all(unread: false) @@ -79,8 +79,8 @@ class NotificationsController < ApplicationController format.html { redirect_to stream_path } format.mobile { redirect_to stream_path } end - format.xml { render :xml => {}.to_xml } - format.json { render :json => {}.to_json } + format.xml { render xml: {}.to_xml } + format.json { render json: {}.to_json } end end @@ -95,18 +95,4 @@ class NotificationsController < ApplicationController } }.as_json end - - def types - { - "also_commented" => "Notifications::AlsoCommented", - "comment_on_post" => "Notifications::CommentOnPost", - "liked" => "Notifications::Liked", - "mentioned" => "Notifications::MentionedInPost", - "mentioned_in_comment" => "Notifications::MentionedInComment", - "reshared" => "Notifications::Reshared", - "started_sharing" => "Notifications::StartedSharing", - "contacts_birthday" => "Notifications::ContactsBirthday" - } - end - helper_method :types end diff --git a/app/helpers/mobile_helper.rb b/app/helpers/mobile_helper.rb index bdc92d7e6..b3e81885f 100644 --- a/app/helpers/mobile_helper.rb +++ b/app/helpers/mobile_helper.rb @@ -26,7 +26,7 @@ module MobileHelper end def mobile_like_icon(post) - if current_user && current_user.liked?(post) + if current_user&.liked?(post) link_to content_tag(:span, post.likes.size, class: "count like-count"), "#", data: {url: post_like_path(post.id, current_user.like_for(post).id)}, @@ -39,6 +39,20 @@ module MobileHelper end end + def mobile_like_comment_icon(comment) + if current_user&.liked?(comment) + link_to content_tag(:span, comment.likes.size, class: "count like-count"), + "#", + data: {url: comment_like_path(comment.id, current_user.like_for(comment).id)}, + class: "entypo-heart like-action active" + else + link_to content_tag(:span, comment.likes.size, class: "count like-count"), + "#", + data: {url: comment_likes_path(comment.id)}, + class: "entypo-heart like-action inactive" + end + end + def mobile_comment_icon(post) link_to content_tag(:span, post.comments.size, class: "count comment-count"), new_post_comment_path(post), diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 6972d811c..5252dd4fc 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -16,6 +16,8 @@ module NotificationsHelper elsif %w(Notifications::CommentOnPost Notifications::AlsoCommented Notifications::Reshared Notifications::Liked) .include?(note.type) opts.merge!(opts_for_post(note.linked_object)) + elsif note.is_a?(Notifications::LikedComment) + opts.merge!(opts_for_comment(note.linked_object)) elsif note.is_a?(Notifications::ContactsBirthday) opts.merge!(opts_for_birthday(note)) end @@ -33,7 +35,16 @@ module NotificationsHelper post_link: link_to(post_page_title(post), post_path(post), data: {ref: post.id}, - class: "hard_object_link").html_safe + class: "hard_object_link") + } + end + + def opts_for_comment(comment) + { + comment_link: link_to(comment.message.title, + post_path(comment.post, anchor: comment.guid), + data: {ref: comment.id}, + class: "hard_object_link") } end diff --git a/app/mailers/notification_mailers/liked_comment.rb b/app/mailers/notification_mailers/liked_comment.rb new file mode 100644 index 000000000..4da96c2ba --- /dev/null +++ b/app/mailers/notification_mailers/liked_comment.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module NotificationMailers + class LikedComment < NotificationMailers::Base + attr_accessor :like + + delegate :target, to: :like, prefix: true + + def set_headers(like_id) # rubocop:disable Naming/AccessorMethodName + @like = Like.find(like_id) + + @headers[:subject] = I18n.t("notifier.liked_comment.liked", name: @sender.name) + @headers[:in_reply_to] = @headers[:references] = "<#{@like.parent.commentable.guid}@#{AppConfig.pod_uri.host}>" + end + end +end diff --git a/app/models/like.rb b/app/models/like.rb index 70f2745cf..2d3e51702 100644 --- a/app/models/like.rb +++ b/app/models/like.rb @@ -32,7 +32,8 @@ class Like < ApplicationRecord after_destroy do self.parent.update_likes_counter - participation = author.participations.find_by(target_id: target.id) + participation_target_id = parent.is_a?(Comment) ? parent.commentable.id : parent.id + participation = author.participations.find_by(target_id: participation_target_id) participation.unparticipate! if participation.present? end diff --git a/app/models/notifications/liked_comment.rb b/app/models/notifications/liked_comment.rb new file mode 100644 index 000000000..478b6ad81 --- /dev/null +++ b/app/models/notifications/liked_comment.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Notifications + class LikedComment < Notification + def mail_job + Workers::Mail::LikedComment + end + + def popup_translation_key + "notifications.liked_comment" + end + + def deleted_translation_key + "notifications.liked_comment_deleted" + end + + def self.notify(like, _recipient_user_ids) + actor = like.author + target_author = like.target.author + + return unless like.target_type == "Comment" && target_author.local? && actor != target_author + + concatenate_or_create(target_author.owner, like.target, actor).email_the_user(like, actor) + end + end +end diff --git a/app/models/user/social_actions.rb b/app/models/user/social_actions.rb index 8a6369a18..e47ea3368 100644 --- a/app/models/user/social_actions.rb +++ b/app/models/user/social_actions.rb @@ -17,6 +17,12 @@ module User::SocialActions end end + def like_comment!(target, opts={}) + Like::Generator.new(self, target).create!(opts).tap do + update_or_create_participation!(target.commentable) + end + end + def participate_in_poll!(target, answer, opts={}) PollParticipation::Generator.new(self, target, answer).create!(opts).tap do update_or_create_participation!(target) diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 4f6b5f9aa..9f338dd76 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -6,16 +6,19 @@ class UserPreference < ApplicationRecord validate :must_be_valid_email_type VALID_EMAIL_TYPES = - ["someone_reported", - "mentioned", - "mentioned_in_comment", - "comment_on_post", - "private_message", - "started_sharing", - "also_commented", - "liked", - "reshared", - "contacts_birthday"] + %w[ + someone_reported + mentioned + mentioned_in_comment + comment_on_post + private_message + started_sharing + also_commented + liked + liked_comment + reshared + contacts_birthday + ].freeze def must_be_valid_email_type unless VALID_EMAIL_TYPES.include?(self.email_type) diff --git a/app/presenters/comment_presenter.rb b/app/presenters/comment_presenter.rb index 6a290337e..1dc078e2e 100644 --- a/app/presenters/comment_presenter.rb +++ b/app/presenters/comment_presenter.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true class CommentPresenter < BasePresenter - def as_json(opts={}) + def as_json(_opts={}) { id: id, guid: guid, text: message.plain_text_for_json, author: author.as_api_response(:backbone), created_at: created_at, - mentioned_people: mentioned_people.as_api_response(:backbone) + mentioned_people: mentioned_people.as_api_response(:backbone), + interactions: build_interactions_json } end @@ -19,11 +20,44 @@ class CommentPresenter < BasePresenter author: PersonPresenter.new(author).as_api_json, created_at: created_at, mentioned_people: build_mentioned_people_json, - reported: current_user.present? && reports.where(user: current_user).exists? + reported: current_user.present? && reports.exists?(user: current_user), + interactions: build_interaction_state + } + end + + def build_interaction_state + { + liked: current_user.present? && likes.exists?(author: current_user.person), + likes_count: likes_count + } + end + + def build_interactions_json + { + likes: as_api(own_likes(likes)), + likes_count: likes_count } end def build_mentioned_people_json mentioned_people.map {|m| PersonPresenter.new(m).as_api_json } end + + # TODO: Only send the own_like boolean. + # Frontend uses the same methods for post-likes as for comment-likes + # Whenever the frontend will be refactored, just send the own_like boolean, instead of a full list of likes + # The list of likes is already send when API requests the full list. + def own_likes(likes) + if current_user + likes.where(author: current_user.person) + else + likes.none + end + end + + def as_api(collection) + collection.includes(author: :profile).map {|element| + element.as_api_response(:backbone) + } + end end diff --git a/app/presenters/last_three_comments_decorator.rb b/app/presenters/last_three_comments_decorator.rb index 9812d11d5..ad098f5e3 100644 --- a/app/presenters/last_three_comments_decorator.rb +++ b/app/presenters/last_three_comments_decorator.rb @@ -5,9 +5,14 @@ class LastThreeCommentsDecorator @presenter = presenter end - def as_json(options={}) + def as_json(_options={}) + current_user = @presenter.current_user @presenter.as_json.tap do |post| - post[:interactions].merge!(:comments => CommentPresenter.as_collection(@presenter.post.last_three_comments)) + post[:interactions].merge!(comments: CommentPresenter.as_collection( + @presenter.post.last_three_comments, + :as_json, + current_user + )) end end -end \ No newline at end of file +end diff --git a/app/services/comment_service.rb b/app/services/comment_service.rb index 2e23033f8..adb370683 100644 --- a/app/services/comment_service.rb +++ b/app/services/comment_service.rb @@ -14,8 +14,8 @@ class CommentService post_service.find!(post_id).comments.for_a_stream end - def find!(comment_guid) - Comment.find_by!(guid: comment_guid) + def find!(id_or_guid) + Comment.find_by!(comment_key(id_or_guid) => id_or_guid) end def destroy(comment_id) @@ -45,6 +45,11 @@ class CommentService attr_reader :user + # We can assume a guid is at least 16 characters long as we have guids set to hex(8) since we started using them. + def comment_key(id_or_guid) + id_or_guid.to_s.length < 16 ? :id : :guid + end + def post_service @post_service ||= PostService.new(user) end diff --git a/app/services/like_service.rb b/app/services/like_service.rb index e86493813..2728a909a 100644 --- a/app/services/like_service.rb +++ b/app/services/like_service.rb @@ -5,11 +5,17 @@ class LikeService @user = user end - def create(post_id) + def create_for_post(post_id) post = post_service.find!(post_id) user.like!(post) end + def create_for_comment(comment_id) + comment = comment_service.find!(comment_id) + post_service.find!(comment.commentable_id) # checks implicit for visible posts + user.like_comment!(comment) + end + def destroy(like_id) like = Like.find(like_id) if user.owns?(like) @@ -25,6 +31,13 @@ class LikeService user ? likes.order(Arel.sql("author_id = #{user.person.id} DESC")) : likes end + def find_for_comment(comment_id) + comment = comment_service.find!(comment_id) + post_service.find!(comment.post.id) # checks implicit for visible posts + likes = comment.likes + user ? likes.order(Arel.sql("author_id = #{user.person.id} DESC")) : likes + end + def unlike_post(post_id) likes = post_service.find!(post_id).likes likes = likes.order(Arel.sql("author_id = #{user.person.id} DESC")) @@ -36,6 +49,17 @@ class LikeService end end + def unlike_comment(comment_id) + likes = comment_service.find!(comment_id).likes + likes = likes.order(Arel.sql("author_id = #{user.person.id} DESC")) + if !likes.empty? && user.owns?(likes[0]) + user.retract(likes[0]) + true + else + false + end + end + private attr_reader :user @@ -43,4 +67,8 @@ class LikeService def post_service @post_service ||= PostService.new(user) end + + def comment_service + @comment_service ||= CommentService.new(user) + end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 755ae7b5c..211590d79 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -3,7 +3,7 @@ class NotificationService NOTIFICATION_TYPES = { Comment => [Notifications::MentionedInComment, Notifications::CommentOnPost, Notifications::AlsoCommented], - Like => [Notifications::Liked], + Like => [Notifications::Liked, Notifications::LikedComment], StatusMessage => [Notifications::MentionedInPost], Conversation => [Notifications::PrivateMessage], Message => [Notifications::PrivateMessage], @@ -15,6 +15,7 @@ class NotificationService "also_commented" => "Notifications::AlsoCommented", "comment_on_post" => "Notifications::CommentOnPost", "liked" => "Notifications::Liked", + "liked_comment" => "Notifications::LikedComment", "mentioned" => "Notifications::MentionedInPost", "mentioned_in_comment" => "Notifications::MentionedInComment", "reshared" => "Notifications::Reshared", diff --git a/app/views/comments/_comment.mobile.haml b/app/views/comments/_comment.mobile.haml index 8664c7413..aa8c0a2ea 100644 --- a/app/views/comments/_comment.mobile.haml +++ b/app/views/comments/_comment.mobile.haml @@ -21,3 +21,6 @@ %div{class: direction_for(comment.text)} = comment.message.markdownified + .comment-stats + .comment-action + = mobile_like_comment_icon(comment) diff --git a/app/views/notifications/_notification.haml b/app/views/notifications/_notification.haml index cba9824c4..059cdb6fc 100644 --- a/app/views/notifications/_notification.haml +++ b/app/views/notifications/_notification.haml @@ -1,5 +1,8 @@ -.media.stream-element{data: {guid: note.id, type: (types.key(note.type) || "")}, - class: (note.unread ? "unread" : "read")} +.media.stream-element{data: { + guid: note.id, + type: (NotificationService::NOTIFICATIONS_JSON_TYPES.key(note.type) || "") + }, + class: (note.unread ? "unread" : "read")} .unread-toggle.pull-right %i.entypo-eye{title: (note.unread ? t("notifications.index.mark_read") : t("notifications.index.mark_unread"))} - if note.type == "Notifications::StartedSharing" && (!defined?(no_aspect_dropdown) || !no_aspect_dropdown) diff --git a/app/views/notifications/_notification.mobile.haml b/app/views/notifications/_notification.mobile.haml index b38977152..918379a3f 100644 --- a/app/views/notifications/_notification.mobile.haml +++ b/app/views/notifications/_notification.mobile.haml @@ -1,5 +1,8 @@ -.notification_element{data: {guid: note.id, type: (types.key(note.type) || "")}, - class: (note.unread ? "unread" : "read")} +.notification_element{data: { + guid: note.id, + type: (NotificationService::NOTIFICATIONS_JSON_TYPES.key(note.type) || "") + }, + class: (note.unread ? "unread" : "read")} .pull-right.unread-toggle %i.entypo-eye{title: (note.unread ? t("notifications.index.mark_read") : t("notifications.index.mark_unread"))} = person_image_tag note.actors.first, :thumb_small diff --git a/app/views/notifications/index.html.haml b/app/views/notifications/index.html.haml index 283eca5a3..92b2ddbfe 100644 --- a/app/views/notifications/index.html.haml +++ b/app/views/notifications/index.html.haml @@ -21,7 +21,7 @@ - case key - when "also_commented", "comment_on_post" %i.entypo-comment - - when "liked" + - when "liked", "liked_comment" %i.entypo-heart - when "mentioned", "mentioned_in_comment" %span.mentionIcon diff --git a/app/views/notifier/liked.html.haml b/app/views/notifier/liked.html.haml index 96cf3770f..6aeda8848 100644 --- a/app/views/notifier/liked.html.haml +++ b/app/views/notifier/liked.html.haml @@ -1,10 +1,10 @@ - if @notification.like_target.public? %p - #{t('.liked', name: @notification.sender_name)}: + #{t(".liked", name: @notification.sender_name)}: = post_message(@notification.like_target, html: true) - else %p - #{t('notifier.liked.limited_post', name: @notification.sender_name)}. + #{t(".limited_post", name: @notification.sender_name)}. %p = link_to t(".view_post"), post_url(@notification.like_target) diff --git a/app/views/notifier/liked.text.erb b/app/views/notifier/liked.text.erb index ce743ade3..4036c2d2e 100644 --- a/app/views/notifier/liked.text.erb +++ b/app/views/notifier/liked.text.erb @@ -3,7 +3,7 @@ <%= post_message(@notification.like_target) %> <% else %> -<%= "#{t("notifier.liked.limited_post", name: @notification.sender_name)}." %> +<%= "#{t(".limited_post", name: @notification.sender_name)}." %> <% end %> <%= t(".view_post") %> diff --git a/app/views/notifier/liked_comment.html.haml b/app/views/notifier/liked_comment.html.haml new file mode 100644 index 000000000..04805d615 --- /dev/null +++ b/app/views/notifier/liked_comment.html.haml @@ -0,0 +1,10 @@ +- if @notification.like_target.public? + %p + #{t(".liked", name: @notification.sender_name)}: + = post_message(@notification.like_target, html: true) +- else + %p + #{t(".limited_post", name: @notification.sender_name)}. + +%p + = link_to t(".view_comment"), post_url(@notification.like_target.root, anchor: @notification.like_target.guid) diff --git a/app/views/notifier/liked_comment.text.erb b/app/views/notifier/liked_comment.text.erb new file mode 100644 index 000000000..f05118959 --- /dev/null +++ b/app/views/notifier/liked_comment.text.erb @@ -0,0 +1,10 @@ +<% if @notification.like_target.public? %> +<%= "#{t(".liked", name: @notification.sender_name)}:" %> + +<%= post_message(@notification.like_target) %> +<% else %> +<%= "#{t(".limited_post", name: @notification.sender_name)}." %> +<% end %> + +<%= t(".view_comment") %> +<%= post_url(@notification.like_target.root, anchor: @notification.like_target.guid) %> diff --git a/app/views/shared/_stream_element.mobile.haml b/app/views/shared/_stream_element.mobile.haml index 4fd5aba2c..36b55e09d 100644 --- a/app/views/shared/_stream_element.mobile.haml +++ b/app/views/shared/_stream_element.mobile.haml @@ -19,21 +19,19 @@ - if post.is_a?(StatusMessage) = render "status_messages/status_message", post: post, photos: post.photos - .bottom-bar.nsfw-hidden{class: ("inactive" unless defined?(expanded_info) && expanded_info)} - = render partial: "comments/post_stats", locals: {post: post} - - - if defined?(expanded_info) && expanded_info - != show_comments_link(post, "active") + - expanded_info = defined?(expanded_info) && expanded_info + .bottom-bar.nsfw-hidden{class: ("inactive" unless expanded_info)} + .post-actions-container + != show_comments_link(post, expanded_info ? "active" : "") + = render partial: "comments/post_stats", locals: {post: post} + - if expanded_info .comment-container %ul.comments = render partial: "comments/comment", collection: post.comments.for_a_stream, locals: {post: post} - - else - != show_comments_link(post) - .ajax-loader.hidden .loader .spinner - .add-comment-switcher{class: ("hidden" unless defined?(expanded_info) && expanded_info)} + .add-comment-switcher{class: ("hidden" unless expanded_info)} = render partial: "comments/new_comment", locals: {post_id: post.id} diff --git a/app/views/users/_edit.haml b/app/views/users/_edit.haml index e20bcff44..ce94d8a99 100644 --- a/app/views/users/_edit.haml +++ b/app/views/users/_edit.haml @@ -153,6 +153,11 @@ = t(".liked") .small-horizontal-spacer + = type.label :liked_comment, class: "checkbox-inline" do + = type.check_box :liked_comment, {checked: email_prefs["liked_comment"]}, false, true + = t(".liked_comment") + .small-horizontal-spacer + = type.label :reshared, class: "checkbox-inline" do = type.check_box :reshared, {checked: email_prefs["reshared"]}, false, true = t(".reshared") diff --git a/app/workers/mail/liked.rb b/app/workers/mail/liked.rb index 8d5ca0db2..4d1ca86e5 100644 --- a/app/workers/mail/liked.rb +++ b/app/workers/mail/liked.rb @@ -12,4 +12,3 @@ module Workers end end end - diff --git a/app/workers/mail/liked_comment.rb b/app/workers/mail/liked_comment.rb new file mode 100644 index 000000000..fa00a0f24 --- /dev/null +++ b/app/workers/mail/liked_comment.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Workers + module Mail + class LikedComment < Liked + end + end +end diff --git a/config/locales/diaspora/en.yml b/config/locales/diaspora/en.yml index 08b902d00..d9890580a 100644 --- a/config/locales/diaspora/en.yml +++ b/config/locales/diaspora/en.yml @@ -632,22 +632,8 @@ en: likes: create: error: "Failed to like." - fail: "Like creation has failed" destroy: error: "Failed to unlike." - people_like_this: - zero: "No likes" - one: "%{count} like" - other: "%{count} likes" - people_like_this_comment: - zero: "No likes" - one: "%{count} like" - other: "%{count} likes" - people_dislike_this: - zero: "No dislikes" - one: "%{count} dislike" - other: "%{count} dislikes" - not_found: "Post or like not found" notifications: started_sharing: @@ -676,6 +662,10 @@ en: zero: "%{actors} have liked your post %{post_link}." one: "%{actors} has liked your post %{post_link}." other: "%{actors} have liked your post %{post_link}." + liked_comment: + zero: "%{actors} have liked your comment %{comment_link}." + one: "%{actors} has liked your comment %{comment_link}." + other: "%{actors} have liked your comment %{comment_link}." reshared: zero: "%{actors} have reshared your post %{post_link}." one: "%{actors} has reshared your post %{post_link}." @@ -688,6 +678,10 @@ en: zero: "%{actors} commented on a deleted post." one: "%{actors} commented on a deleted post." other: "%{actors} commented on a deleted post." + liked_comment_deleted: + zero: "%{actors} liked your deleted comment." + one: "%{actors} liked your deleted comment." + other: "%{actors} liked your deleted comment." liked_post_deleted: zero: "%{actors} liked your deleted post." one: "%{actors} liked your deleted post." @@ -713,7 +707,8 @@ en: all_notifications: "All notifications" also_commented: "Also commented" comment_on_post: "Comment on post" - liked: "Liked" + liked: "Liked post" + liked_comment: "Liked comment" mentioned: "Mentioned in post" mentioned_in_comment: "Mentioned in comment" reshared: "Reshared" @@ -760,6 +755,10 @@ en: liked: "%{name} liked your post" limited_post: "%{name} liked your limited post" view_post: "View post >" + liked_comment: + liked: "%{name} liked your comment" + limited_post: "%{name} liked your comment on a limited post" + view_comment: "View comment >" reshared: reshared: "%{name} reshared your post" view_post: "View post >" @@ -1307,6 +1306,7 @@ en: mentioned: "you are mentioned in a post" mentioned_in_comment: "you are mentioned in a comment" liked: "someone likes your post" + liked_comment: "someone likes your comment" reshared: "someone reshares your post" comment_on_post: "someone comments on your post" also_commented: "someone comments on a post you’ve commented on" diff --git a/features/desktop/comments.feature b/features/desktop/comments.feature index 0ef2ca6c8..9e655e56c 100644 --- a/features/desktop/comments.feature +++ b/features/desktop/comments.feature @@ -86,3 +86,14 @@ Feature: commenting When I click on selector ".toggle_post_comments" Then I should see "Comment 2" + + Scenario: Like a comment in stream view + When "alice@alice.alice" has commented "That's cool" on "Look at this dog" + And I am on "alice@alice.alice"'s page + And I like the comment "That's cool" + Then I should see a like within comment "That's cool" + + When I expand likes within comment "That's cool" + Then I should see a micro avatar within comment "That's cool" + When I unlike comment "That's cool" + Then I should not see a micro avatar within comment "That's cool" diff --git a/features/mobile/reactions.feature b/features/mobile/reactions.feature index 81133e871..0dcc0e246 100644 --- a/features/mobile/reactions.feature +++ b/features/mobile/reactions.feature @@ -43,3 +43,14 @@ Feature: reactions mobile post And I click on selector "a.comment-action" And I confirm the alert after I click on selector "a.remove" Then I should see "0 comments" within ".show-comments" + + Scenario: liking and unliking a comment + When I click on selector "a.comment-action.inactive" + And I fill in the following: + | text | is that a poodle? | + And I press "Comment" + Then I should see "is that a poodle?" within ".comment-container" + When I toggle like on comment with text "is that a poodle?" + Then I should see a like on comment with text "is that a poodle?" + When I toggle like on comment with text "is that a poodle?" + Then I should see an unliked comment with text "is that a poodle?" diff --git a/features/step_definitions/comment_steps.rb b/features/step_definitions/comment_steps.rb index 9a8ad9eeb..a6fbb4979 100644 --- a/features/step_definitions/comment_steps.rb +++ b/features/step_definitions/comment_steps.rb @@ -44,3 +44,38 @@ end When /^I enter "([^"]*)" in the comment field$/ do |comment_text| find("textarea.comment-box.mention-textarea").native.send_keys(comment_text) end + +Then /^I like the comment "([^"]*)"$/ do |comment_text| + comment_guid = Comment.find_by(text: comment_text).guid + # Find like like-link within comment-block + find(id: comment_guid).click_link("Like") +end + +Then /^I should see a like within comment "([^"]*)"$/ do |comment_text| + comment_guid = Comment.find_by(text: comment_text).guid + block = find(id: comment_guid) + expect(block).to have_css(".expand-likes") +end + +When /^I expand likes within comment "([^"]*)"$/ do |comment_text| + comment_guid = Comment.find_by(text: comment_text).guid + find(id: comment_guid).click_link("1 Like") + find(id: comment_guid).find(".entypo-heart").hover # unfocus avatar to get rid of tooltip +end + +When /^I unlike comment "([^"]*)"$/ do |comment_text| + comment_guid = Comment.find_by(text: comment_text).guid + find(id: comment_guid).click_link("Unlike") +end + +Then /^I should see a micro avatar within comment "([^"]*)"$/ do |comment_text| + comment_guid = Comment.find_by(text: comment_text).guid + block = find(id: comment_guid) + expect(block).to have_css(".micro.avatar") +end + +Then /^I should not see a micro avatar within comment "([^"]*)"$/ do |comment_text| + comment_guid = Comment.find_by(text: comment_text).guid + block = find(id: comment_guid) + expect(block).not_to have_css(".micro.avatar") +end diff --git a/features/step_definitions/mobile_steps.rb b/features/step_definitions/mobile_steps.rb index abb121444..27ce0db3e 100644 --- a/features/step_definitions/mobile_steps.rb +++ b/features/step_definitions/mobile_steps.rb @@ -23,3 +23,26 @@ Then /^the aspect dropdown within "([^"]*)" should be labeled "([^"]*)"/ do |sel current_scope.should have_css("option.list_cover", text: label) end end + +When /^I toggle like on comment with text "([^"]*)"$/ do |comment_text| + comment_guid = Comment.find_by(text: comment_text).guid + within(id: comment_guid) do + find(".entypo-heart.like-action").click + end +end + +Then /^I should see a like on comment with text "([^"]*)"$/ do |comment_text| + comment_guid = Comment.find_by(text: comment_text).guid + within(id: comment_guid) do + find(".entypo-heart.like-action.active") + expect(find(".count.like-count")).to have_text "1" + end +end + +Then /^I should see an unliked comment with text "([^"]*)"$/ do |comment_text| + comment_guid = Comment.find_by(text: comment_text).guid + within(id: comment_guid) do + find(".entypo-heart.like-action.inactive") + expect(find(".count.like-count")).to have_text "0" + end +end diff --git a/features/step_definitions/notifications_steps.rb b/features/step_definitions/notifications_steps.rb index 9c521a908..5e5ffab9d 100644 --- a/features/step_definitions/notifications_steps.rb +++ b/features/step_definitions/notifications_steps.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true When "I filter notifications by likes" do - step %(I follow "Liked" within "#notifications_container .list-group") + step %(I follow "Liked post" within "#notifications_container .list-group") end When "I filter notifications by mentions" do diff --git a/lib/schemas/api_v1.json b/lib/schemas/api_v1.json index 2d807610a..2a0014391 100644 --- a/lib/schemas/api_v1.json +++ b/lib/schemas/api_v1.json @@ -101,9 +101,18 @@ "type": "array", "items": { "$ref": "https://diaspora.software/api/v1/schema.json#/definitions/short_profile" } }, - "reported": { "type": "boolean" } + "reported": { "type": "boolean" }, + "interactions": { + "type": "object", + "properties" : { + "liked" : { "type": "boolean" }, + "likes_count" : { "type": "integer" } + }, + "required": ["liked", "likes_count"], + "additionalProperties": false + } }, - "required": ["guid", "created_at", "author", "body", "reported"], + "required": ["guid", "created_at", "author", "body", "reported", "interactions"], "additionalProperties": false } }, @@ -192,6 +201,7 @@ "also_commented", "comment_on_post", "liked", + "liked_comment", "mentioned", "mentioned_in_comment", "reshared", diff --git a/spec/controllers/notifications_controller_spec.rb b/spec/controllers/notifications_controller_spec.rb index 079089edc..9fe300b6b 100644 --- a/spec/controllers/notifications_controller_spec.rb +++ b/spec/controllers/notifications_controller_spec.rb @@ -83,6 +83,7 @@ describe NotificationsController, type: :controller do "comment_on_post" => 0, "contacts_birthday" => 0, "liked" => 0, + "liked_comment" => 0, "mentioned" => 0, "mentioned_in_comment" => 0, "reshared" => 0, diff --git a/spec/integration/api/comments_controller_spec.rb b/spec/integration/api/comments_controller_spec.rb index 3c90195c9..3db9823c2 100644 --- a/spec/integration/api/comments_controller_spec.rb +++ b/spec/integration/api/comments_controller_spec.rb @@ -159,6 +159,23 @@ describe Api::V1::CommentsController do expect_to_match_json_schema(comments.to_json, "#/definitions/comments") end + it "retrieves own like state" do + like_service.create_for_comment(@comment1.id) + + get( + api_v1_post_comments_path(post_id: @status.guid), + params: {access_token: access_token} + ) + expect(response.status).to eq(200) + comments = response_body(response) + expect(comments[0]["interactions"]["liked"]).to eq(true) + expect(comments[0]["interactions"]["likes_count"]).to eq(1) + expect(comments[1]["interactions"]["liked"]).to eq(false) + expect(comments[1]["interactions"]["likes_count"]).to eq(0) + + expect_to_match_json_schema(comments.to_json, "#/definitions/comments") + end + it "returns reported status of a comment" do auth_minimum_scopes.user.reports.create!(item: @comment1, text: "Meh!") get( @@ -443,6 +460,10 @@ describe Api::V1::CommentsController do CommentService.new(user) end + def like_service(user=auth.user) + LikeService.new(user) + end + def response_body(response) JSON.parse(response.body) end diff --git a/spec/integration/api/likes_controller_spec.rb b/spec/integration/api/likes_controller_spec.rb index 463919268..e70381494 100644 --- a/spec/integration/api/likes_controller_spec.rb +++ b/spec/integration/api/likes_controller_spec.rb @@ -61,9 +61,9 @@ describe Api::V1::LikesController do end it "succeeds in getting post with likes" do - like_service(bob).create(@status.guid) - like_service(auth.user).create(@status.guid) - like_service(alice).create(@status.guid) + like_service(bob).create_for_post(@status.guid) + like_service(auth.user).create_for_post(@status.guid) + like_service(alice).create_for_post(@status.guid) get( api_v1_post_likes_path(post_id: @status.guid), params: {access_token: access_token_minimum_scopes} @@ -112,7 +112,7 @@ describe Api::V1::LikesController do describe "#create" do context "with right post id" do - it "succeeeds in liking post" do + it "succeeds in liking post" do post( api_v1_post_likes_path(post_id: @status.guid), params: {access_token: access_token} @@ -181,7 +181,7 @@ describe Api::V1::LikesController do describe "#delete" do before do - like_service.create(@status.guid) + like_service.create_for_post(@status.guid) end context "with right post id" do @@ -225,7 +225,7 @@ describe Api::V1::LikesController do context "with improper credentials" do it "fails at unliking private post without private:read" do - like_service(auth_public_only.user).create(@private_status.guid) + like_service(auth_public_only.user).create_for_post(@private_status.guid) delete( api_v1_post_likes_path(post_id: @private_status.guid), params: {access_token: access_token} @@ -234,7 +234,7 @@ describe Api::V1::LikesController do end it "fails in unliking post without interactions" do - like_service(auth_minimum_scopes.user).create(@status.guid) + like_service(auth_minimum_scopes.user).create_for_post(@status.guid) delete( api_v1_post_likes_path(post_id: @status.guid), params: {access_token: access_token_minimum_scopes} diff --git a/spec/javascripts/jasmine_helpers/factory.js b/spec/javascripts/jasmine_helpers/factory.js index 9b0d4ecfc..4222c4c89 100644 --- a/spec/javascripts/jasmine_helpers/factory.js +++ b/spec/javascripts/jasmine_helpers/factory.js @@ -43,13 +43,15 @@ var factory = { comment : function(overrides) { var defaultAttrs = { - "created_at" : "2012-01-04T00:55:30Z", - "author" : this.author(), - "guid" : this.guid(), - "id" : this.id.next(), - "text" : "This is a comment!" + "created_at": "2012-01-04T00:55:30Z", + "author": this.author(), + "guid": this.guid(), + "id": this.id.next(), + "text": "This is a comment!" }; + overrides = overrides || {}; + overrides.post = this.post(); return new app.models.Comment(_.extend(defaultAttrs, overrides)); }, diff --git a/spec/mailers/notifier_spec.rb b/spec/mailers/notifier_spec.rb index ead4a115a..3928d34a0 100644 --- a/spec/mailers/notifier_spec.rb +++ b/spec/mailers/notifier_spec.rb @@ -212,6 +212,27 @@ describe Notifier, type: :mailer do end end + describe ".liked_comment" do + before do + @post = FactoryBot.create(:status_message, author: alice.person, public: true) + @comment = FactoryBot.create(:comment, author: alice.person, post: @post) + @like = @comment.likes.create!(author: bob.person) + @mail = Notifier.send_notification("liked_comment", alice.id, @like.author.id, @like.id) + end + + it "TO: goes to the right person" do + expect(@mail.to).to eq([alice.email]) + end + + it "BODY: contains the original comment" do + expect(@mail.body.encoded).to include(@comment.message.plain_text) + end + + it "BODY: contains the name of person liking" do + expect(@mail.body.encoded).to include(@like.author.name) + end + end + describe ".reshared" do before do @post = FactoryBot.create(:status_message, author: alice.person, public: true) @@ -489,6 +510,42 @@ describe Notifier, type: :mailer do expect(mail.body.encoded).to include(bob.name) end end + + describe ".liked_comment" do + let(:comment) { alice.comment!(limited_post, "Totally is") } + let(:like) { bob.like_comment!(comment) } + let(:mail) { Notifier.send_notification("liked_comment", alice.id, bob.person.id, like.id) } + + it "TO: goes to the right person" do + expect(mail.to).to eq([alice.email]) + end + + it "FROM: contains the sender's name" do + expect(mail["From"].to_s).to eq("\"#{pod_name} (#{bob.name})\" <#{AppConfig.mail.sender_address}>") + end + + it "FROM: removes emojis from sender's name" do + bob.person.profile.update!(first_name: "1️⃣2️3️⃣ Numbers 123", last_name: "👍✅👍🏻Emojis😀😇❄️") + expect(mail["From"].to_s).to eq("\"#{pod_name} (Numbers 123 Emojis)\" <#{AppConfig.mail.sender_address}>") + end + + it "SUBJECT: does not show the limited comment" do + expect(mail.subject).not_to include("Totally is") + end + + it "IN-REPLY-TO and REFERENCES: references the liked post" do + expect(mail.in_reply_to).to eq("#{limited_post.guid}@#{AppConfig.pod_uri.host}") + expect(mail.references).to eq("#{limited_post.guid}@#{AppConfig.pod_uri.host}") + end + + it "BODY: does not show the limited post" do + expect(mail.body.encoded).not_to include("Totally is") + end + + it "BODY: contains the name of person liking" do + expect(mail.body.encoded).to include(bob.name) + end + end end describe ".confirm_email" do diff --git a/spec/models/like_spec.rb b/spec/models/like_spec.rb index d8e214eb5..487b9fce4 100644 --- a/spec/models/like_spec.rb +++ b/spec/models/like_spec.rb @@ -12,20 +12,34 @@ describe Like, type: :model do end describe "#destroy" do - before do - @like = alice.like!(status) - end + let!(:like) { alice.like!(status) } it "should delete a participation" do - expect { @like.destroy }.to change { Participation.count }.by(-1) + expect { like.destroy }.to change { Participation.count }.by(-1) end it "should decrease count participation" do alice.comment!(status, "Are you there?") - @like.destroy - participations = Participation.where(target_id: @like.target_id, author_id: @like.author_id) + like.destroy + participations = Participation.where(target_id: status.id, author_id: like.author_id) expect(participations.first.count).to eq(1) end + + context "on comment" do + let(:comment) { bob.comment!(status, "Are you there?") } + let!(:like) { alice.like_comment!(comment) } + + it "should delete a participation" do + expect { like.destroy }.to change { Participation.count }.by(-1) + end + + it "should decrease count participation" do + alice.comment!(status, "Yes, I am here!") + like.destroy + participations = Participation.where(target_id: status.id, author_id: like.author_id) + expect(participations.first.count).to eq(1) + end + end end describe "counter cache" do diff --git a/spec/presenters/likes_presenter_spec.rb b/spec/presenters/likes_presenter_spec.rb index 314c91d08..d3fd12635 100644 --- a/spec/presenters/likes_presenter_spec.rb +++ b/spec/presenters/likes_presenter_spec.rb @@ -10,7 +10,7 @@ describe LikesPresenter do to: "all" ) bobs_like_service = LikeService.new(bob) - like = bobs_like_service.create(@status.guid) + like = bobs_like_service.create_for_post(@status.guid) @presenter = LikesPresenter.new(like, bob) end diff --git a/spec/services/like_service_spec.rb b/spec/services/like_service_spec.rb index d52e495d6..a97cd8052 100644 --- a/spec/services/like_service_spec.rb +++ b/spec/services/like_service_spec.rb @@ -2,67 +2,129 @@ describe LikeService do let(:post) { alice.post(:status_message, text: "hello", to: alice.aspects.first) } + let(:alice_comment) { CommentService.new(alice).create(post.id, "This is a wonderful post") } + let(:bobs_comment) { CommentService.new(bob).create(post.id, "My post was better than yours") } - describe "#create" do + describe "#create_for_post" do it "creates a like on my own post" do expect { - LikeService.new(alice).create(post.id) + LikeService.new(alice).create_for_post(post.id) }.not_to raise_error end it "creates a like on a post of a contact" do expect { - LikeService.new(bob).create(post.id) + LikeService.new(bob).create_for_post(post.id) }.not_to raise_error end it "attaches the like to the post" do - like = LikeService.new(alice).create(post.id) + like = LikeService.new(alice).create_for_post(post.id) expect(post.likes.first.id).to eq(like.id) end it "fails if the post does not exist" do expect { - LikeService.new(bob).create("unknown id") + LikeService.new(bob).create_for_post("unknown id") }.to raise_error ActiveRecord::RecordNotFound end it "fails if the user can't see the post" do expect { - LikeService.new(eve).create(post.id) + LikeService.new(eve).create_for_post(post.id) }.to raise_error ActiveRecord::RecordNotFound end it "fails if the user already liked the post" do - LikeService.new(alice).create(post.id) + LikeService.new(alice).create_for_post(post.id) expect { - LikeService.new(alice).create(post.id) + LikeService.new(alice).create_for_post(post.id) + }.to raise_error ActiveRecord::RecordInvalid + end + end + + describe "#create_for_comment" do + it "creates a like on a posts comment" do + expect { + LikeService.new(alice).create_for_comment(alice_comment.id) + }.not_to raise_error + end + + it "creates a like on someone else comment" do + expect { + LikeService.new(alice).create_for_comment(bobs_comment.id) + }.not_to raise_error + end + + it "attaches the like to the comment" do + like = LikeService.new(alice).create_for_comment(bobs_comment.id) + expect(bobs_comment.likes.first.id).to eq(like.id) + end + + it "fails if comment does not exist" do + expect { + LikeService.new(alice).create_for_comment("unknown_id") + }.to raise_error ActiveRecord::RecordNotFound + end + + it "fails if user cant see post and its comments" do + expect { + LikeService.new(eve).create_for_comment(bobs_comment.id) + }.to raise_error ActiveRecord::RecordNotFound + end + + it "fails if user already liked the comment" do + LikeService.new(alice).create_for_comment(bobs_comment.id) + expect { + LikeService.new(alice).create_for_comment(bobs_comment.id) }.to raise_error ActiveRecord::RecordInvalid end end describe "#destroy" do - let(:like) { LikeService.new(bob).create(post.id) } + context "for post like" do + let(:like) { LikeService.new(bob).create_for_post(post.id) } - it "lets the user destroy their own like" do - result = LikeService.new(bob).destroy(like.id) - expect(result).to be_truthy + it "lets the user destroy their own like" do + result = LikeService.new(bob).destroy(like.id) + expect(result).to be_truthy + end + + it "doesn't let the parent author destroy others likes" do + result = LikeService.new(alice).destroy(like.id) + expect(result).to be_falsey + end + + it "doesn't let someone destroy others likes" do + result = LikeService.new(eve).destroy(like.id) + expect(result).to be_falsey + end + + it "fails if the like doesn't exist" do + expect { + LikeService.new(bob).destroy("unknown id") + }.to raise_error ActiveRecord::RecordNotFound + end end - it "doesn't let the parent author destroy others likes" do - result = LikeService.new(alice).destroy(like.id) - expect(result).to be_falsey - end + context "for comment like" do + let(:like) { LikeService.new(bob).create_for_comment(alice_comment.id) } - it "doesn't let someone destroy others likes" do - result = LikeService.new(eve).destroy(like.id) - expect(result).to be_falsey - end + it "let the user destroy its own comment like" do + result = LikeService.new(bob).destroy(like.id) + expect(result).to be_truthy + end - it "fails if the like doesn't exist" do - expect { - LikeService.new(bob).destroy("unknown id") - }.to raise_error ActiveRecord::RecordNotFound + it "doesn't let the parent author destroy other comment likes" do + result = LikeService.new(alice).destroy(like.id) + expect(result).to be_falsey + end + + it "fails if the like doesn't exist" do + expect { + LikeService.new(alice).destroy("unknown id") + }.to raise_error ActiveRecord::RecordNotFound + end end end @@ -70,17 +132,17 @@ describe LikeService do context "with user" do it "returns likes for a public post" do post = alice.post(:status_message, text: "hello", public: true) - like = LikeService.new(alice).create(post.id) + like = LikeService.new(alice).create_for_post(post.id) expect(LikeService.new(eve).find_for_post(post.id)).to include(like) end it "returns likes for a visible private post" do - like = LikeService.new(alice).create(post.id) + like = LikeService.new(alice).create_for_post(post.id) expect(LikeService.new(bob).find_for_post(post.id)).to include(like) end it "doesn't return likes for a private post the user can not see" do - LikeService.new(alice).create(post.id) + LikeService.new(alice).create_for_post(post.id) expect { LikeService.new(eve).find_for_post(post.id) }.to raise_error ActiveRecord::RecordNotFound @@ -88,7 +150,7 @@ describe LikeService do it "returns the user's like first" do post = alice.post(:status_message, text: "hello", public: true) - [alice, bob, eve].map {|user| LikeService.new(user).create(post.id) } + [alice, bob, eve].map {|user| LikeService.new(user).create_for_post(post.id) } [alice, bob, eve].each do |user| expect( @@ -101,12 +163,12 @@ describe LikeService do context "without user" do it "returns likes for a public post" do post = alice.post(:status_message, text: "hello", public: true) - like = LikeService.new(alice).create(post.id) + like = LikeService.new(alice).create_for_post(post.id) expect(LikeService.new.find_for_post(post.id)).to include(like) end it "doesn't return likes a for private post" do - LikeService.new(alice).create(post.id) + LikeService.new(alice).create_for_post(post.id) expect { LikeService.new.find_for_post(post.id) }.to raise_error Diaspora::NonPublic @@ -115,15 +177,68 @@ describe LikeService do it "returns all likes of a post" do post = alice.post(:status_message, text: "hello", public: true) - likes = [alice, bob, eve].map {|user| LikeService.new(user).create(post.id) } + likes = [alice, bob, eve].map {|user| LikeService.new(user).create_for_post(post.id) } expect(LikeService.new.find_for_post(post.id)).to match_array(likes) end end + describe "#find_for_comment" do + context "with user" do + it "returns likes for a public post comment" do + post = alice.post(:status_message, text: "hello", public: true) + comment = CommentService.new(bob).create(post.id, "Hello comment") + like = LikeService.new(alice).create_for_comment(comment.id) + expect(LikeService.new(eve).find_for_comment(comment.id)).to include(like) + end + + it "returns likes for visible private post comments" do + comment = CommentService.new(bob).create(post.id, "Hello comment") + like = LikeService.new(alice).create_for_comment(comment.id) + expect(LikeService.new(bob).find_for_comment(comment.id)).to include(like) + end + + it "doesn't return likes for a posts comment the user can not see" do + expect { + LikeService.new(eve).find_for_comment(alice_comment.id) + }.to raise_error ActiveRecord::RecordNotFound + end + + it "returns the user's like first" do + post = alice.post(:status_message, text: "hello", public: true) + comment = CommentService.new(alice).create(post.id, "I like my own post") + + [alice, bob, eve].map {|user| LikeService.new(user).create_for_comment(comment.id) } + [alice, bob, eve].each do |user| + expect( + LikeService.new(user).find_for_comment(comment.id).first.author.id + ).to be user.person.id + end + end + end + + context "without user" do + it "returns likes for a comment on a public post" do + post = alice.post(:status_message, text: "hello", public: true) + comment = CommentService.new(bob).create(post.id, "I like my own post") + like = LikeService.new(alice).create_for_comment(comment.id) + expect( + LikeService.new.find_for_comment(comment.id) + ).to include(like) + end + + it "doesn't return likes for a private post comment" do + LikeService.new(alice).create_for_comment(alice_comment.id) + expect { + LikeService.new.find_for_comment(alice_comment.id) + }.to raise_error Diaspora::NonPublic + end + end + end + describe "#unlike_post" do before do - LikeService.new(alice).create(post.id) + LikeService.new(alice).create_for_post(post.id) end it "removes the like to the post" do @@ -131,4 +246,16 @@ describe LikeService do expect(post.likes.length).to eq(0) end end + + describe "#unlike_comment" do + it "removes the like for a comment" do + comment = CommentService.new(alice).create(post.id, "I like my own post") + LikeService.new(alice).create_for_comment(comment.id) + expect(comment.likes.length).to eq(1) + + LikeService.new(alice).unlike_comment(comment.id) + comment = CommentService.new(alice).find!(comment.id) + expect(comment.likes.length).to eq(0) + end + end end