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 %>