diff --git a/app/assets/stylesheets/application/messages.css b/app/assets/stylesheets/application/messages.css index a60daf44..b7b01b8e 100644 --- a/app/assets/stylesheets/application/messages.css +++ b/app/assets/stylesheets/application/messages.css @@ -441,6 +441,11 @@ color: var(--color-text-muted, #666); font-size: 0.95em; } + + &[data-unread="true"] .txt-muted { + color: var(--color-text); + font-weight: 800; + } } .thread__avatar { diff --git a/app/controllers/rooms_controller.rb b/app/controllers/rooms_controller.rb index 79860ae0..b7977a83 100644 --- a/app/controllers/rooms_controller.rb +++ b/app/controllers/rooms_controller.rb @@ -64,6 +64,7 @@ def ensure_can_administer def find_messages messages = @room.messages + .with_threads .with_rich_text_body_and_embeds .with_attached_attachment .preload(creator: :avatar_attachment) @@ -81,13 +82,13 @@ def find_messages if @room.thread? && @room.parent_message.present? if result.empty? # Empty thread - show just the parent message - result = [@room.parent_message] + result = [ @room.parent_message ] elsif result.any? # Thread has messages - prepend parent if we're showing the first message first_thread_message = @room.messages.ordered.first messages_array = result.to_a if first_thread_message && messages_array.first.id == first_thread_message.id - result = [@room.parent_message] + messages_array + result = [ @room.parent_message ] + messages_array end end end diff --git a/app/models/membership.rb b/app/models/membership.rb index 9e60dc06..6b64e4d7 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -4,6 +4,10 @@ class Membership < ApplicationRecord belongs_to :room belongs_to :user + def self.broadcast_involvement_to(user_id:, room_id:, involvement:) + ActionCable.server.broadcast "user_#{user_id}_involvements", { roomId: room_id, involvement: involvement } + end + has_many :unread_notifications, ->(membership) { scope = since(membership.unread_at || Time.current) @@ -50,6 +54,7 @@ class Membership < ApplicationRecord } after_update_commit { user.reset_remote_connections if deactivated? } + after_update_commit :promote_thread_memberships, if: :starred_parent_room? after_destroy_commit { user.reset_remote_connections } enum involvement: %w[ invisible nothing mentions everything ].index_by(&:itself), _prefix: :involved_in @@ -123,6 +128,25 @@ def ensure_receives_mentions! private + def starred_parent_room? + saved_change_to_involvement? && involved_in_everything? && !room.thread? + end + + def promote_thread_memberships + thread_ids = [] + ApplicationRecord.transaction do + room.threads.find_each do |thread| + updated = thread.memberships.where(user_id: user_id, involvement: "invisible") + .update_all(involvement: "mentions", updated_at: Time.current) + thread_ids << thread.id if updated.positive? + end + end + # Manual broadcast since update_all bypasses callbacks + thread_ids.each do |tid| + Membership.broadcast_involvement_to(user_id: user_id, room_id: tid, involvement: "mentions") + end + end + def broadcast_read ActionCable.server.broadcast "user_#{user_id}_reads", { room_id: room_id } end diff --git a/app/models/room.rb b/app/models/room.rb index aa8a1d29..11cf1759 100644 --- a/app/models/room.rb +++ b/app/models/room.rb @@ -163,6 +163,14 @@ def display_name(for_user: nil) end end + def unread_for?(user) + memberships + .where(user_id: user.id) + .where.not(involvement: :invisible) + .where.not(unread_at: nil) + .exists? + end + private def set_sortable_name self.sortable_name = name.to_s.gsub(/[[:^ascii:]\p{So}]/, "").strip.downcase diff --git a/app/models/rooms/thread.rb b/app/models/rooms/thread.rb index 5fdf1041..ee4d831c 100644 --- a/app/models/rooms/thread.rb +++ b/app/models/rooms/thread.rb @@ -2,6 +2,8 @@ class Rooms::Thread < Room validates_presence_of :parent_message + after_create_commit :promote_starred_users_to_visible + def default_involvement(user: nil) if user.present? && (user == creator || user == parent_message&.creator) "everything" @@ -9,4 +11,23 @@ def default_involvement(user: nil) "invisible" end end + + private + def promote_starred_users_to_visible + return unless parent_message&.room + + user_ids = parent_message.room.memberships.active.involved_in_everything.pluck(:user_id) + scope = memberships.where(user_id: user_ids, involvement: "invisible") + affected_user_ids = scope.pluck(:user_id) + return if affected_user_ids.empty? + + ApplicationRecord.transaction do + scope.update_all(involvement: "mentions", updated_at: Time.current) + end + + # Manual broadcast since update_all bypasses callbacks + affected_user_ids.each do |uid| + Membership.broadcast_involvement_to(user_id: uid, room_id: id, involvement: "mentions") + end + end end diff --git a/app/views/messages/_threads.html.erb b/app/views/messages/_threads.html.erb index 7900a85a..2352a8a9 100644 --- a/app/views/messages/_threads.html.erb +++ b/app/views/messages/_threads.html.erb @@ -12,7 +12,7 @@ <% ordered_participants = participant_users.map { |id| participants[id] }.compact %> <% reply_count = thread.messages_count || thread.messages.active.count %> - <%= link_to room_path(thread), class: "thread__link flex-inline align-center gap", data: { turbo_frame: "_top", updated_at: thread.last_active_at.iso8601 } do %> + <%= link_to room_path(thread), class: "thread__link flex-inline align-center gap", data: { turbo_frame: "_top", updated_at: thread.last_active_at.iso8601, unread: thread.unread_for?(Current.user) } do %>
<% ordered_participants.each do |user| %>
diff --git a/campfire-mods.md b/campfire-mods.md index 58ef94d1..b97ec283 100644 --- a/campfire-mods.md +++ b/campfire-mods.md @@ -118,6 +118,16 @@ For us, threads work like message boards in Basecamp, and they've been great at - https://github.com/antiwork/smallbets/compare/7333c40abc545c1900d4e23cfcef0fb557b2290e...9ad6e5d0a57957907a5911a30778212dabfa5e48 +## Unread thread indicators +Members can see which threads have unread messages by looking for a bold "Last reply" timestamp. This only shows for threads they participate in (authored parent message, replied, mentioned, or starred the parent room). Users who star a room are automatically added as participants in all threads in that room. + +- [`app/models/room.rb`](app/models/room.rb) - `unread_for?(user)` method +- [`app/models/rooms/thread.rb`](app/models/rooms/thread.rb) - Promotion callback +- [`app/models/membership.rb`](app/models/membership.rb) - Starring promotion + broadcasts +- [`app/views/messages/_threads.html.erb`](app/views/messages/_threads.html.erb) - Data attribute +- [`app/assets/stylesheets/application/messages.css`](app/assets/stylesheets/application/messages.css) - Styling + + ## Block pings Members can block users from sending them direct messages. Admins can monitor which members are getting blocked.