diff --git a/app/controllers/boosts_controller.rb b/app/controllers/boosts_controller.rb
index 166b7e634..5f5c95e93 100644
--- a/app/controllers/boosts_controller.rb
+++ b/app/controllers/boosts_controller.rb
@@ -2,6 +2,6 @@ class BoostsController < ApplicationController
include BubbleScoped, BucketScoped
def create
- @bubble.boosts.create!
+ @bubble.boost!
end
end
diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb
index 3a6a650ac..d9ce0616c 100644
--- a/app/controllers/comments_controller.rb
+++ b/app/controllers/comments_controller.rb
@@ -2,12 +2,7 @@ class CommentsController < ApplicationController
include BubbleScoped, BucketScoped
def create
- @bubble.comments.create!(comment_params)
+ @bubble.comment! params.dig(:comment, :body).presence
redirect_to bucket_bubble_url(@bucket, @bubble)
end
-
- private
- def comment_params
- params.require(:comment).permit(:body)
- end
end
diff --git a/app/helpers/bubbles_helper.rb b/app/helpers/bubbles_helper.rb
index 4d47f7d11..8bdf50648 100644
--- a/app/helpers/bubbles_helper.rb
+++ b/app/helpers/bubbles_helper.rb
@@ -8,7 +8,7 @@ module BubblesHelper
end
def bubble_size(bubble)
- activity = bubble.boosts.size + bubble.comments.size
+ activity = bubble.boost_count + bubble.comments.size
rank =
case activity
when 0..5 then "one"
diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb
deleted file mode 100644
index 18586dbe5..000000000
--- a/app/helpers/comments_helper.rb
+++ /dev/null
@@ -1,80 +0,0 @@
-module CommentsHelper
- def render_comments_and_boosts(bubble)
- combined_collection = combine_and_sort_items(bubble)
-
- safe_join([
- render_creator_summary(bubble, combined_collection),
- render_remaining_items(combined_collection)
- ])
- end
-
- private
- def combine_and_sort_items(bubble)
- (bubble.comments + bubble.boosts + bubble.assignments).sort_by(&:created_at)
- end
-
- def render_creator_summary(bubble, combined_collection)
- content_tag(:div, class: "comment--upvotes flex-inline flex-wrap align-start gap fill-white border-radius center position-relative") do
- [
- creator_info(bubble),
- initial_assignment_info(combined_collection),
- render_initial_boosts(combined_collection)
- ].compact.join(", ").html_safe
- end
- end
-
- def creator_info(bubble)
- "Added by #{bubble.creator.name} #{time_ago_in_words(bubble.created_at)} ago"
- end
-
- def initial_assignment_info(combined_collection)
- initial_assignment = combined_collection.find { |item| item.is_a?(Assignment) }
- "assigned to #{initial_assignment.assignee.name}" if initial_assignment
- end
-
- def render_initial_boosts(combined_collection)
- grouped_boosts = combined_collection.take_while { |item| item.is_a?(Boost) }
- return if grouped_boosts.empty?
-
- user_boosts = grouped_boosts.group_by(&:creator).transform_values(&:count)
- boost_summaries = user_boosts.map { |user, count| "#{user.name} +#{count}" }
- boost_summaries.to_sentence
- end
-
- def render_remaining_items(combined_collection)
- initial_count = combined_collection.take_while { |item| !item.is_a?(Comment) }.count
- items = combined_collection.drop(initial_count)
-
- safe_join(items.chunk_while { |i, j| grouped_item?(i) && grouped_item?(j) }.map do |chunk|
- if chunk.first.is_a?(Comment)
- render "comments/comment", comment: chunk.first
- else
- render_grouped_items(chunk)
- end
- end)
- end
-
- def grouped_item?(item)
- item.is_a?(Boost) || item.is_a?(Assignment)
- end
-
- def render_grouped_items(items)
- return if items.empty?
-
- content_tag(:div, class: "comment--upvotes flex-inline flex-wrap align-start gap fill-white border-radius center position-relative") do
- [
- render_grouped_boosts(items.select { |item| item.is_a?(Boost) }),
- render_grouped_assignments(items.select { |item| item.is_a?(Assignment) })
- ].flatten.compact.to_sentence.html_safe
- end
- end
-
- def render_grouped_boosts(boosts)
- return if boosts.empty?
- boosts.group_by(&:creator).map { |user, user_boosts| "#{user.name} +#{user_boosts.count}" }
- end
-
- def render_grouped_assignments(assignments)
- assignments.map { |assignment| "Assigned to #{assignment.assignee.name} #{time_ago_in_words(assignment.created_at)} ago" }
- end
-end
diff --git a/app/models/boost.rb b/app/models/boost.rb
deleted file mode 100644
index 41b0edb7a..000000000
--- a/app/models/boost.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-class Boost < ApplicationRecord
- belongs_to :bubble
- belongs_to :creator, class_name: "User", default: -> { Current.user }
-end
diff --git a/app/models/bubble.rb b/app/models/bubble.rb
index 43a6d05f8..debbf14cd 100644
--- a/app/models/bubble.rb
+++ b/app/models/bubble.rb
@@ -1,12 +1,9 @@
class Bubble < ApplicationRecord
- include Assignable, Colored, Poppable, Searchable, Taggable
+ include Assignable, Boostable, Colored, Commentable, Eventable, Poppable, Searchable, Taggable, Threaded
belongs_to :bucket
belongs_to :creator, class_name: "User", default: -> { Current.user }
- has_many :comments, dependent: :destroy
- has_many :boosts, dependent: :destroy
-
has_one_attached :image, dependent: :purge_later
searchable_by :title, using: :bubbles_search_index
@@ -14,7 +11,7 @@ class Bubble < ApplicationRecord
before_save :set_default_title
scope :reverse_chronologically, -> { order(created_at: :desc, id: :desc) }
- scope :ordered_by_activity, -> { left_joins(:comments, :boosts).group(:id).order(Arel.sql("COUNT(comments.id) + COUNT(boosts.id) DESC")) }
+ scope :ordered_by_activity, -> { left_joins(:comments).group(:id).order(Arel.sql("COUNT(comments.id) + boost_count DESC")) }
scope :mentioning, ->(query) do
bubbles = search(query).select(:id).to_sql
diff --git a/app/models/bubble/assignable.rb b/app/models/bubble/assignable.rb
index 3abf18d39..828034dab 100644
--- a/app/models/bubble/assignable.rb
+++ b/app/models/bubble/assignable.rb
@@ -2,7 +2,7 @@ module Bubble::Assignable
extend ActiveSupport::Concern
included do
- has_many :assignments, dependent: :destroy
+ has_many :assignments, dependent: :delete_all
has_many :assignees, through: :assignments
has_many :assigners, through: :assignments
@@ -12,8 +12,9 @@ module Bubble::Assignable
end
def assign(users, assigner: Current.user)
- (Array(users) - assignees).tap do |users|
- users.each { |user| assignments.create!(assignee: user, assigner: assigner) }
+ transaction do
+ Assignment.insert_all Array(users).collect { |user| { assignee_id: user.id, assigner_id: assigner.id, bubble_id: id } }
+ track_event :assigned, assignee_ids: Array(users).map(&:id)
end
end
end
diff --git a/app/models/bubble/boostable.rb b/app/models/bubble/boostable.rb
new file mode 100644
index 000000000..98ce28485
--- /dev/null
+++ b/app/models/bubble/boostable.rb
@@ -0,0 +1,10 @@
+module Bubble::Boostable
+ extend ActiveSupport::Concern
+
+ def boost!
+ transaction do
+ increment! :boost_count
+ track_event :boosted
+ end
+ end
+end
diff --git a/app/models/bubble/commentable.rb b/app/models/bubble/commentable.rb
new file mode 100644
index 000000000..911fd3956
--- /dev/null
+++ b/app/models/bubble/commentable.rb
@@ -0,0 +1,11 @@
+module Bubble::Commentable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :comments, dependent: :destroy
+ end
+
+ def comment!(body)
+ comments.create! body:
+ end
+end
diff --git a/app/models/bubble/eventable.rb b/app/models/bubble/eventable.rb
new file mode 100644
index 000000000..e1137880a
--- /dev/null
+++ b/app/models/bubble/eventable.rb
@@ -0,0 +1,14 @@
+module Bubble::Eventable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :events, dependent: :delete_all
+
+ after_create -> { track_event :created }
+ end
+
+ private
+ def track_event(action, creator: Current.user, **particulars)
+ events.create! action:, creator:, particulars:
+ end
+end
diff --git a/app/models/bubble/thread.rb b/app/models/bubble/thread.rb
new file mode 100644
index 000000000..8b0e28be5
--- /dev/null
+++ b/app/models/bubble/thread.rb
@@ -0,0 +1,35 @@
+class Bubble::Thread
+ attr_reader :bubble
+
+ def initialize(bubble)
+ @bubble = bubble
+ end
+
+ def entries
+ sorted_entries.chunk_while { |a, b| consecutive_events?(a, b) }.map.with_index { |entries, index| roll_up(entries, index) }
+ end
+
+ def to_partial_path
+ "bubbles/threads/thread"
+ end
+
+ private
+ delegate :events, :comments, to: :bubble, private: true
+
+ def sorted_entries
+ (events + comments).sort_by(&:created_at)
+ end
+
+ def consecutive_events?(a, b)
+ [ a, b ] in [ Event, Event ]
+ end
+
+ def roll_up(entries, position)
+ case entries.first
+ when Comment
+ entries.sole
+ when Event
+ Rollup.new self, entries, first_position: position.zero?
+ end
+ end
+end
diff --git a/app/models/bubble/thread/rollup.rb b/app/models/bubble/thread/rollup.rb
new file mode 100644
index 000000000..dd034aa8b
--- /dev/null
+++ b/app/models/bubble/thread/rollup.rb
@@ -0,0 +1,55 @@
+class Bubble::Thread::Rollup
+ def initialize(thread, entries, first_position: false)
+ @thread = thread
+ @entries = entries
+ @first_position = first_position
+ end
+
+ def body
+ collapsed_entries.map { |entry, chunk_size| summarize(entry, chunk_size) }.to_sentence.upcase_first
+ end
+
+ def to_partial_path
+ "bubbles/threads/rollup"
+ end
+
+ private
+ attr_reader :thread, :entries, :first_position
+
+ delegate :time_ago_in_words, to: "ApplicationController.helpers", private: true
+
+ def collapsed_entries
+ sorted_entries.chunk_while { |a, b| repeated_boosts?(a, b) }.map { |chunk| [ chunk.last, chunk.size ] }
+ end
+
+ def sorted_entries
+ entries.sort_by do |entry|
+ case entry.action
+ when "created" then [ 1, entry.created_at ]
+ when "assigned" then [ 2, entry.created_at ]
+ when "boosted" then [ 3, entry.creator, entry.created_at ]
+ end
+ end
+ end
+
+ def repeated_boosts?(a, b)
+ a.action == "boosted" && a.slice(:action, :creator_id) == b.slice(:action, :creator_id)
+ end
+
+ def summarize(entry, chunk_size)
+ case entry.action
+ when "created"
+ "added by #{entry.creator.name} #{time_ago_in_words(entry.created_at)} ago"
+ when "assigned"
+ summary = "assigned to #{entry.assignee_names.to_sentence}"
+ summary += " #{time_ago_in_words(entry.created_at)} ago" unless first_position?
+ summary
+ when "boosted"
+ "#{entry.creator.name} +#{chunk_size}"
+ end
+ end
+
+ def first_position?
+ first_position
+ end
+end
diff --git a/app/models/bubble/threaded.rb b/app/models/bubble/threaded.rb
new file mode 100644
index 000000000..22885f62a
--- /dev/null
+++ b/app/models/bubble/threaded.rb
@@ -0,0 +1,5 @@
+module Bubble::Threaded
+ def thread
+ Bubble::Thread.new self
+ end
+end
diff --git a/app/models/current.rb b/app/models/current.rb
index e5546b93b..d534f3e58 100644
--- a/app/models/current.rb
+++ b/app/models/current.rb
@@ -1,6 +1,10 @@
class Current < ActiveSupport::CurrentAttributes
- attribute :session
+ attribute :session, :user
- delegate :user, to: :session, allow_nil: true
delegate :account, to: :user, allow_nil: true
+
+ def session=(session)
+ super
+ self.user = session.user
+ end
end
diff --git a/app/models/event.rb b/app/models/event.rb
new file mode 100644
index 000000000..8994acc05
--- /dev/null
+++ b/app/models/event.rb
@@ -0,0 +1,10 @@
+class Event < ApplicationRecord
+ THREADABLE_ACTIONS = %w[ assigned boosted created ]
+
+ include Assignments
+
+ belongs_to :creator, class_name: "User"
+ belongs_to :bubble
+
+ scope :threadable, -> { where action: THREADABLE_ACTIONS }
+end
diff --git a/app/models/event/assignments.rb b/app/models/event/assignments.rb
new file mode 100644
index 000000000..ade131d31
--- /dev/null
+++ b/app/models/event/assignments.rb
@@ -0,0 +1,16 @@
+module Event::Assignments
+ extend ActiveSupport::Concern
+
+ included do
+ store_accessor :particulars, :assignee_ids
+ end
+
+ def assignee_names
+ assignees.map &:name
+ end
+
+ private
+ def assignees
+ @assignees ||= creator.account.users.find assignee_ids
+ end
+end
diff --git a/app/views/boosts/_boosts.html.erb b/app/views/boosts/_boosts.html.erb
index 1e2b1998b..973107476 100644
--- a/app/views/boosts/_boosts.html.erb
+++ b/app/views/boosts/_boosts.html.erb
@@ -1,6 +1,6 @@
<%= button_to bucket_bubble_boosts_path(bubble.bucket, bubble),
- class: "btn btn--plain",
- data: { action: "toggle-class#toggle" },
- form_class: "full-width" do %>
- + <%= bubble.boosts.size if bubble.boosts.any? %>
+ class: "btn btn--plain",
+ data: { action: "toggle-class#toggle" },
+ form_class: "full-width" do %>
+ + <%= bubble.boost_count if bubble.boost_count.positive? %>
<% end %>
diff --git a/app/views/boosts/create.turbo_stream.erb b/app/views/boosts/create.turbo_stream.erb
index 4481aa4bf..718c9cf0d 100644
--- a/app/views/boosts/create.turbo_stream.erb
+++ b/app/views/boosts/create.turbo_stream.erb
@@ -1,3 +1,7 @@
<%= turbo_stream.update dom_id(@bubble, :boosts) do %>
<%= render "boosts/boosts", bubble: @bubble %>
<% end %>
+
+<%= turbo_stream.replace dom_id(@bubble, :thread) do %>
+ <%= render @bubble.thread %>
+<% end %>
diff --git a/app/views/bubbles/show.html.erb b/app/views/bubbles/show.html.erb
index ee9cfbad8..9d320c4f7 100644
--- a/app/views/bubbles/show.html.erb
+++ b/app/views/bubbles/show.html.erb
@@ -25,7 +25,4 @@
<%= render "bubbles/bubble", bubble: @bubble %>
-