From dd42cca8803ffb7983c80388edd737b071b8195b Mon Sep 17 00:00:00 2001 From: Hunter Madison Date: Wed, 31 Jan 2018 08:43:07 -0500 Subject: [PATCH] Show story and comment replies, tracking unread ones --- .travis.yml | 4 +- Gemfile | 3 ++ Gemfile.lock | 7 +++ app/assets/stylesheets/application.css | 5 ++ app/controllers/replies_controller.rb | 54 +++++++++++++++++++ app/controllers/stories_controller.rb | 10 ++++ app/helpers/application_helper.rb | 19 ++++++- app/helpers/replies_helper.rb | 11 ++++ app/helpers/stories_helper.rb | 8 +++ app/models/application_record.rb | 3 ++ app/models/read_ribbon.rb | 10 ++++ app/models/replying_comment.rb | 16 ++++++ app/models/user.rb | 4 ++ app/views/comments/_comment.html.erb | 5 +- app/views/comments/threads.html.erb | 1 - app/views/replies/show.html.erb | 44 +++++++++++++++ app/views/stories/show.html.erb | 3 +- config/initializers/senic.rb | 3 ++ config/routes.rb | 3 ++ ...130235553_add_read_notification_support.rb | 13 +++++ db/schema.rb | 17 +++++- db/views/replying_comments_v01.sql | 27 ++++++++++ 22 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 app/controllers/replies_controller.rb create mode 100644 app/helpers/replies_helper.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/read_ribbon.rb create mode 100644 app/models/replying_comment.rb create mode 100644 app/views/replies/show.html.erb create mode 100644 config/initializers/senic.rb create mode 100644 db/migrate/20180130235553_add_read_notification_support.rb create mode 100644 db/views/replying_comments_v01.sql diff --git a/.travis.yml b/.travis.yml index 74af7c06c6..3df2303ba6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,9 @@ language: ruby services: - mysql env: - - DATABASE_URL=mysql2://root:@localhost/lobsters_test + global: + - DATABASE_URL=mysql2://root:@localhost/lobsters_dev + - RAILS_ENV=test before_script: - ./bin/rails db:create - ./bin/rails db:schema:load diff --git a/Gemfile b/Gemfile index b5b83c2c4f..4eb299700e 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,9 @@ gem "mysql2", ">= 0.3.14" # uncomment to use PostgreSQL # gem "pg" +gem 'scenic' +gem 'scenic-mysql' + gem "uglifier", ">= 1.3.0" gem "jquery-rails", "~> 4.3" gem "dynamic_form" diff --git a/Gemfile.lock b/Gemfile.lock index cae1f354ba..3b83a8324f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -132,6 +132,11 @@ GEM rspec-support (3.6.0) ruby-enum (0.7.1) i18n + scenic (1.4.0) + activerecord (>= 4.0.0) + railties (>= 4.0.0) + scenic-mysql (0.1.0) + scenic (>= 1.3) sprockets (3.7.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) @@ -175,6 +180,8 @@ DEPENDENCIES rotp rqrcode rspec-rails (~> 3.6) + scenic + scenic-mysql sqlite3 uglifier (>= 1.3.0) unicorn diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d44e38bb77..03893e4e02 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -922,6 +922,11 @@ div.comment_form_container textarea { width: 100%; } +span.comment_unread { + color: red; + font-weight: 600; +} + /* trees */ .tree, diff --git a/app/controllers/replies_controller.rb b/app/controllers/replies_controller.rb new file mode 100644 index 0000000000..27b1920948 --- /dev/null +++ b/app/controllers/replies_controller.rb @@ -0,0 +1,54 @@ +class RepliesController < ApplicationController + REPLIES_PER_PAGE = 25 + + before_action :require_logged_in_user_or_400 + after_action :update_read_ribbons + + def show + @page = params[:page].to_i + if @page == 0 + @page = 1 + elsif @page < 0 || @page > (2 ** 32) + raise ActionController::RoutingError.new("page out of bounds") + end + + @filter = params[:filter] || 'unread' + + case @filter + when 'comments' + @heading = @title = "Your Comment Replies" + @replies = ReplyingComment + .comment_replies_for(@user.id) + .offset((@page - 1) * REPLIES_PER_PAGE) + .limit(REPLIES_PER_PAGE) + when 'stories' + @heading = @title = "Your Story Replies" + @replies = ReplyingComment + .story_replies_for(@user.id) + .offset((@page - 1) * REPLIES_PER_PAGE) + .limit(REPLIES_PER_PAGE) + when 'all' + @heading = @title = "All Your Replies" + @replies = ReplyingComment + .for_user(@user.id) + .offset((@page - 1) * REPLIES_PER_PAGE) + .limit(REPLIES_PER_PAGE) + else + @heading = @title = "Your Unread Replies" + @replies = ReplyingComment.unread_replies_for(@user.id) + end + end + + private + + def update_read_ribbons + return unless @filter == 'unread' + stories = @replies.pluck(:story_id).uniq + + stories.each do |story| + ribbon = ReadRibbon.find_by(user_id: @user.id, story_id: story) + ribbon.updated_at = Time.now + ribbon.save! + end + end +end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index d9ae71d286..527528af5c 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -8,6 +8,7 @@ class StoriesController < ApplicationController before_action :find_user_story, :only => [ :destroy, :edit, :undelete, :update ] before_action :find_story!, :only => [ :suggest, :submit_suggestions ] + around_action :track_story_reads, only: [ :show ], if: -> { @user.present? } def create @title = "Submit Story" @@ -292,6 +293,7 @@ def hide end HiddenStory.hide_story_for_user(story.id, @user.id) + ReadRibbon.hide_replies_for(story.id, @user.id) render :plain => "ok" end @@ -407,4 +409,12 @@ def verify_user_can_submit_stories return redirect_to "/" end end + + def track_story_reads + story = Story.where(short_id: params[:id]).first! + @ribbon = ReadRibbon.where(user_id: @user.id, story_id: story.id).first_or_create + yield + @ribbon.updated_at = Time.now + @ribbon.save + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cd66040b68..4e4e323fcb 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -79,6 +79,17 @@ def right_header_links @right_header_links = {} if @user + if (count = @user.unread_replies_count) > 0 + @right_header_links.merge!({ "/replies" => { + :class => [ "new_messages" ], + :title => "Replies (#{count})", + } }) + else + @right_header_links.merge!({ + "/replies" => { :title => "Replies" } + }) + end + if (count = @user.unread_message_count) > 0 @right_header_links.merge!({ "/messages" => { :class => [ "new_messages" ], @@ -170,6 +181,12 @@ def time_ago_in_words_label(time, options = {}) ago = "#{years} year#{years == 1 ? "" : "s"} ago" end - raw(content_tag(:span, ago, :title => time.strftime("%F %T %z"))) + span_class = '' + + if options[:mark_unread] + span_class += 'comment_unread' + end + + raw(content_tag(:span, ago, title: time.strftime("%F %T %z"), class: span_class)) end end diff --git a/app/helpers/replies_helper.rb b/app/helpers/replies_helper.rb new file mode 100644 index 0000000000..96dd9c70ee --- /dev/null +++ b/app/helpers/replies_helper.rb @@ -0,0 +1,11 @@ +module RepliesHelper + def link_to_filter(name) + title = name.titleize + + if @filter != name + link_to(title, replies_path(filter: name)) + else + title + end + end +end diff --git a/app/helpers/stories_helper.rb b/app/helpers/stories_helper.rb index c6d1547309..3ab0a8a30a 100644 --- a/app/helpers/stories_helper.rb +++ b/app/helpers/stories_helper.rb @@ -16,4 +16,12 @@ def show_guidelines? false end + + def is_unread?(comment) + if !@user || !@ribbon + return false + end + + (comment.created_at > @ribbon.updated_at) && (comment.user_id != @user.id) + end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000000..10a4cba84d --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/app/models/read_ribbon.rb b/app/models/read_ribbon.rb new file mode 100644 index 0000000000..5f73b50c70 --- /dev/null +++ b/app/models/read_ribbon.rb @@ -0,0 +1,10 @@ +class ReadRibbon < ApplicationRecord + belongs_to :user + belongs_to :story + + def self.hide_replies_for(story_id, user_id) + ribbon = find_by(user_id: user_id, story_id: story_id) + ribbon.is_following = false + ribbon.save! + end +end diff --git a/app/models/replying_comment.rb b/app/models/replying_comment.rb new file mode 100644 index 0000000000..b2cb41ab4a --- /dev/null +++ b/app/models/replying_comment.rb @@ -0,0 +1,16 @@ +class ReplyingComment < ApplicationRecord + attribute :is_unread, :boolean + + belongs_to :comment + + scope :for_user, ->(user_id) { where(user_id: user_id).order(comment_created_at: :desc) } + scope :unread_replies_for, ->(user_id) { for_user(user_id).where(is_unread: true) } + scope :comment_replies_for, ->(user_id) { for_user(user_id).where('parent_comment_id is not null') } + scope :story_replies_for, ->(user_id) { for_user(user_id).where('parent_comment_id is null') } + + protected + # This is a view, not a real table + def readonly? + true + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 66358f6813..42cec1a187 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -505,6 +505,10 @@ def update_unread_message_count! self.received_messages.unread.count) end + def unread_replies_count + ReplyingComment.where(user_id: self.id, is_unread: true).count + end + def votes_for_others self.votes.joins(:story, :comment). where("comments.user_id <> votes.user_id AND " << diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index 11aaecf377..9614e42b3e 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -73,8 +73,11 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ? <% elsif comment.is_from_email? %> e-mailed <% end %> + <% if defined?(is_unread) && is_unread %> + <% mark_unread = true %> + <% end %> <%= time_ago_in_words_label((comment.has_been_edited? ? - comment.updated_at : comment.created_at)) %> + comment.updated_at : comment.created_at), strip_about: true, mark_unread: mark_unread) %> <% end %> <% if !comment.previewing %> diff --git a/app/views/comments/threads.html.erb b/app/views/comments/threads.html.erb index 9b05a05821..49e256fd68 100644 --- a/app/views/comments/threads.html.erb +++ b/app/views/comments/threads.html.erb @@ -1,4 +1,3 @@ - <% @threads.each do |thread| %>
    <% comments_by_parent = thread.group_by(&:parent_comment_id) %> diff --git a/app/views/replies/show.html.erb b/app/views/replies/show.html.erb new file mode 100644 index 0000000000..176d658bf9 --- /dev/null +++ b/app/views/replies/show.html.erb @@ -0,0 +1,44 @@ +
    +
    + <%= link_to_filter('unread') %> | + <%= link_to_filter('all') %> | + <%= link_to_filter('comments') %> | + <%= link_to_filter('stories') %> + +
    + +
    + <%= @heading %> +
    +
    + +
      + <% @replies.each do |reply| %> +
    1. + <%= render "comments/comment", + comment: reply.comment, + show_story: true, + is_unread: reply.is_unread, + show_tree_lines: false %> +
        +
      1. + <% end %> +
      + +<% if @replies.count > RepliesController::REPLIES_PER_PAGE && @filter != 'unread'%> + +<% end %> diff --git a/app/views/stories/show.html.erb b/app/views/stories/show.html.erb index b3d51c3fb9..3d1e813d70 100644 --- a/app/views/stories/show.html.erb +++ b/app/views/stories/show.html.erb @@ -56,7 +56,8 @@ :show_story => (comment.story_id != @story.id), :show_tree_lines => true, :was_merged => (comment.story_id != @story.id), - :children => children %> + :children => children, + :is_unread => is_unread?(comment) %> <% if ancestors.present? %>
      diff --git a/config/initializers/senic.rb b/config/initializers/senic.rb new file mode 100644 index 0000000000..2768991c99 --- /dev/null +++ b/config/initializers/senic.rb @@ -0,0 +1,3 @@ +Scenic.configure do |config| + config.database = Scenic::Adapters::Mysql.new +end diff --git a/config/routes.rb b/config/routes.rb index 43e0518327..d653f1b8d8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,9 @@ get "/threads" => "comments#threads" get "/threads/:user" => "comments#threads" + get "/replies" => "replies#show" + get "/replies/page/:page" => "replies#show" + get "/login" => "login#index" post "/login" => "login#login" post "/logout" => "login#logout" diff --git a/db/migrate/20180130235553_add_read_notification_support.rb b/db/migrate/20180130235553_add_read_notification_support.rb new file mode 100644 index 0000000000..cc5638bc33 --- /dev/null +++ b/db/migrate/20180130235553_add_read_notification_support.rb @@ -0,0 +1,13 @@ +class AddReadNotificationSupport < ActiveRecord::Migration[5.1] + def change + create_table :read_ribbons do |t| + t.boolean :is_following, default: true + t.timestamps + end + + add_reference :read_ribbons, :user, index: true + add_reference :read_ribbons, :story, index: true + + create_view :replying_comments + end +end diff --git a/db/schema.rb b/db/schema.rb index 65faa01b02..31358166c5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180124143340) do +ActiveRecord::Schema.define(version: 20180130235553) do create_table "comments", id: :integer, unsigned: true, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t| t.datetime "created_at", null: false @@ -115,6 +115,16 @@ t.index ["created_at"], name: "index_moderations_on_created_at" end + create_table "read_ribbons", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| + t.boolean "is_following", default: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "user_id" + t.bigint "story_id" + t.index ["story_id"], name: "index_read_ribbons_on_story_id" + t.index ["user_id"], name: "index_read_ribbons_on_user_id" + end + create_table "saved_stories", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -236,4 +246,9 @@ t.index ["user_id", "story_id"], name: "user_id_story_id" end + + create_view "replying_comments", sql_definition: <<-SQL + select `lobsters_dev`.`read_ribbons`.`user_id` AS `user_id`,`lobsters_dev`.`comments`.`id` AS `comment_id`,`lobsters_dev`.`read_ribbons`.`story_id` AS `story_id`,`lobsters_dev`.`comments`.`parent_comment_id` AS `parent_comment_id`,`lobsters_dev`.`comments`.`created_at` AS `comment_created_at`,`parent_comments`.`user_id` AS `parent_comment_author_id`,`lobsters_dev`.`comments`.`user_id` AS `comment_author_id`,`lobsters_dev`.`stories`.`user_id` AS `story_author_id`,(`lobsters_dev`.`read_ribbons`.`updated_at` < `lobsters_dev`.`comments`.`created_at`) AS `is_unread` from (((`lobsters_dev`.`read_ribbons` join `lobsters_dev`.`comments` on((`lobsters_dev`.`comments`.`story_id` = `lobsters_dev`.`read_ribbons`.`story_id`))) join `lobsters_dev`.`stories` on((`lobsters_dev`.`stories`.`id` = `lobsters_dev`.`comments`.`story_id`))) left join `lobsters_dev`.`comments` `parent_comments` on((`parent_comments`.`id` = `lobsters_dev`.`comments`.`parent_comment_id`))) where ((`lobsters_dev`.`read_ribbons`.`is_following` = 1) and (`lobsters_dev`.`comments`.`user_id` <> `lobsters_dev`.`read_ribbons`.`user_id`) and ((`parent_comments`.`user_id` = `lobsters_dev`.`read_ribbons`.`user_id`) or (isnull(`parent_comments`.`user_id`) and (`lobsters_dev`.`stories`.`user_id` = `lobsters_dev`.`read_ribbons`.`user_id`))) and ((`lobsters_dev`.`comments`.`upvotes` - `lobsters_dev`.`comments`.`downvotes`) < 0) and ((`parent_comments`.`upvotes` - `parent_comments`.`downvotes`) < 0)) + SQL + end diff --git a/db/views/replying_comments_v01.sql b/db/views/replying_comments_v01.sql new file mode 100644 index 0000000000..51f9546263 --- /dev/null +++ b/db/views/replying_comments_v01.sql @@ -0,0 +1,27 @@ +SELECT + read_ribbons.user_id, + comments.id as comment_id, + read_ribbons.story_id as story_id, + comments.parent_comment_id, + comments.created_at as comment_created_at, + parent_comments.user_id as parent_comment_author_id, + comments.user_id as comment_author_id, + stories.user_id as story_author_id, + (read_ribbons.updated_at < comments.created_at) as is_unread +FROM + read_ribbons +JOIN + comments ON comments.story_id = read_ribbons.story_id +JOIN + stories ON stories.id = comments.story_id +LEFT JOIN + comments parent_comments ON parent_comments.id = comments.parent_comment_id +WHERE + read_ribbons.is_following = 1 + AND comments.user_id != read_ribbons.user_id + AND + (parent_comments.user_id = read_ribbons.user_id + OR (parent_comments.user_id IS NULL + AND stories.user_id = read_ribbons.user_id)) + AND (comments.upvotes - comments.downvotes) < 0 + AND (parent_comments.upvotes - parent_comments.downvotes) < 0;