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..e1d2fdc84 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.count
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/javascript/controllers/animation_controller.js b/app/javascript/controllers/animation_controller.js
index a6fe306a2..9fd0998d8 100644
--- a/app/javascript/controllers/animation_controller.js
+++ b/app/javascript/controllers/animation_controller.js
@@ -1,5 +1,5 @@
import { Controller } from "@hotwired/stimulus"
-import { nextFrame } from "helpers/timing_helpers"
+import { nextFrame } from "helpers"
export default class extends Controller {
static classes = [ "play" ]
diff --git a/app/javascript/controllers/filter_controller.js b/app/javascript/controllers/filter_controller.js
index 815b05952..953ae3a29 100644
--- a/app/javascript/controllers/filter_controller.js
+++ b/app/javascript/controllers/filter_controller.js
@@ -1,5 +1,5 @@
import { Controller } from "@hotwired/stimulus"
-import { debounce } from "helpers/timing_helpers"
+import { debounce } from "helpers"
export default class extends Controller {
static targets = [ "list" ]
diff --git a/app/javascript/controllers/thread_controller.js b/app/javascript/controllers/thread_controller.js
new file mode 100644
index 000000000..1004505b3
--- /dev/null
+++ b/app/javascript/controllers/thread_controller.js
@@ -0,0 +1,14 @@
+import { Controller } from "@hotwired/stimulus"
+import { current } from "helpers"
+
+export default class extends Controller {
+ static classes = [ "myComment" ]
+
+ connect() {
+ this.#myComments.forEach(comment => comment.classList.add(this.myCommentClass))
+ }
+
+ get #myComments() {
+ return this.element.querySelectorAll(`.comment[data-creator-id='${current.user.id}']`)
+ }
+}
diff --git a/app/javascript/helpers/current_helpers.js b/app/javascript/helpers/current_helpers.js
new file mode 100644
index 000000000..6d2d9fdb2
--- /dev/null
+++ b/app/javascript/helpers/current_helpers.js
@@ -0,0 +1,25 @@
+// On-demand JavaScript objects from "current" HTML elements. Example:
+//
+//
+//
+//
+// >> current.identity
+// => { id: "123", timeZoneName: "Central Time (US & Canada)" }
+//
+// >> current.foo
+// => {}
+ export const current = new Proxy({}, {
+ get(_target, propertyName) {
+ const result = {}
+ const prefix = `current-${propertyName}-`
+ for (const { name, content } of document.head.querySelectorAll(`meta[name^=${prefix}]`)) {
+ const key = camelize(name.slice(prefix.length))
+ result[key] = content
+ }
+ return result
+ }
+ })
+
+ function camelize(string) {
+ return string.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase())
+ }
diff --git a/app/javascript/helpers/index.js b/app/javascript/helpers/index.js
new file mode 100644
index 000000000..634fd65a2
--- /dev/null
+++ b/app/javascript/helpers/index.js
@@ -0,0 +1,2 @@
+export * from "helpers/current_helpers"
+export * from "helpers/timing_helpers"
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..3c39d93fa
--- /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, boost_count:
+ end
+ end
+end
diff --git a/app/models/bubble/commentable.rb b/app/models/bubble/commentable.rb
new file mode 100644
index 000000000..d0ff9c521
--- /dev/null
+++ b/app/models/bubble/commentable.rb
@@ -0,0 +1,11 @@
+module Bubble::Commentable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :comments, dependent: :delete_all
+ 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..1b8dda3c0
--- /dev/null
+++ b/app/models/bubble/thread.rb
@@ -0,0 +1,39 @@
+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
+
+ def cache_key
+ ActiveSupport::Cache.expand_cache_key bubble, :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, index)
+ case entries.first
+ when Comment
+ entries.sole
+ when Event
+ Rollup.new self, entries, first_position: index.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..be4a49bb2
--- /dev/null
+++ b/app/models/bubble/thread/rollup.rb
@@ -0,0 +1,59 @@
+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
+
+ def cache_key
+ ActiveSupport::Cache.expand_cache_key [ thread, entries.first, entries.last ], "rollup"
+ end
+
+ private
+ attr_reader :thread, :entries, :first_position
+
+ delegate :time_ago_in_words, to: "ApplicationController.helpers", private: true
+
+ def first_position?
+ first_position
+ end
+
+ def collapsed_entries
+ sorted_entries.chunk_while { |a, b| equivalent_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 equivalent_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
+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 %>
-