Class: Chats::Conversation
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Chats::Conversation
- Defined in:
- lib/chats/models/conversation.rb
Overview
A conversation: either a direct 1:1 thread or a group. Optionally about a host record (the polymorphic subject — a ride, an order, a listing), which is how marketplace-style apps attach a chat to a domain object.
Direct conversations & the direct_key
Two people opening a DM with each other at the same instant must end up in the SAME conversation. We guarantee that with a deterministic direct_key (“the sorted pair of participant keys, plus the subject key when the conversation is about something”) backed by a UNIQUE index, and ‘create_or_find_by!` which turns the index violation into a find. See `.direct_between!`.
The subject participates in the key on purpose: the same pair can have one thread per subject (per-listing threads, marketplace-style) AND one subjectless thread (classic DMs). The host picks the cardinality simply by passing or omitting ‘about:`.
Denormalization
last_message_at and last_message_id exist so the inbox (the hottest query in any messaging product) is one indexed ORDER BY plus one ‘includes(:last_message)` — no MAX() subqueries, no N+1.
Constant Summary collapse
- KINDS =
%w[direct group].freeze
- TITLE_MAX_LENGTH =
120- EPOCH =
Time.utc(1970).freeze
Class Method Summary collapse
-
.direct_between(a, b, about: nil) ⇒ Object
The direct conversation between
aandb(aboutsubject, when given), or nil. -
.direct_between!(a, b, about: nil) ⇒ Object
Find-or-create the direct conversation between two messagers, race-safely (see class comment).
-
.direct_key_for(pair, subject: nil) ⇒ Object
Deterministic identity for a direct pair (+ optional subject).
-
.group!(creator, others, title: nil, about: nil) ⇒ Object
Create a group conversation.
-
.unread_counts_for(messager, conversations) ⇒ Object
Per-conversation unread counts for a messager over a set of conversations, in ONE grouped query — the inbox uses this to render row badges without N+1: Chats::Conversation.unread_counts_for(user, conversations) # => { id => count }.
Instance Method Summary collapse
-
#add_participant!(messager, role: "member") ⇒ Object
Idempotent, race-safe membership.
-
#direct? ⇒ Boolean
— Predicates & lookups ————————————————-.
- #group? ⇒ Boolean
- #mark_read_by!(messager) ⇒ Object
- #moderation_content_type ⇒ Object
- #moderation_label ⇒ Object
- #moderation_snapshot(field) ⇒ Object
- #other_participants(messager) ⇒ Object
- #participant?(messager) ⇒ Boolean
- #participant_for(messager) ⇒ Object
-
#post_system_message!(body) ⇒ Object
Post a message from your APP into the conversation — “Your ride was cancelled”, “Payment received” — rendered as a centered system note, not a bubble.
-
#recompute_last_message! ⇒ Object
:nodoc:.
-
#register_last_message!(message) ⇒ Object
Denormalized pointers the inbox sorts/previews by.
- #removable_reported_field?(field) ⇒ Boolean
- #remove_reported_field!(field) ⇒ Object
- #report_visible_to?(viewer, field: nil) ⇒ Boolean
-
#reported_owner ⇒ Object
— Moderation contract (duck-typed, zero coupling) ————————.
-
#subject_label ⇒ Object
A short human label for the subject (“Madrid → Barcelona”, “Order #4221”), provided by the subject model via ‘chat_subject_label` (see Chats::ChatSubject).
-
#title_for(viewer) ⇒ Object
What this conversation is called from
viewer‘s seat: a direct thread is named after the counterpart; a group after its title (or its members, when untitled). -
#unread_count_for(messager) ⇒ Object
— Read state ———————————————————–.
- #unread_for?(messager) ⇒ Boolean
Class Method Details
.direct_between(a, b, about: nil) ⇒ Object
The direct conversation between a and b (about subject, when given), or nil. Read-only twin of ‘.direct_between!`.
122 123 124 |
# File 'lib/chats/models/conversation.rb', line 122 def direct_between(a, b, about: nil) find_by(direct_key: direct_key_for([a, b], subject: about)) end |
.direct_between!(a, b, about: nil) ⇒ Object
Find-or-create the direct conversation between two messagers, race-safely (see class comment). Raises:
Chats::BlockedError if the pair is blocked (either direction)
Chats::NotAllowedError if the host `can_message` policy says no
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/chats/models/conversation.rb', line 130 def direct_between!(a, b, about: nil) raise ArgumentError, "both participants are required" if a.nil? || b.nil? raise ArgumentError, "can't open a conversation with yourself" if a == b raise Chats::BlockedError, "messagers are blocked" if Chats.blocked_between?(a, b) raise Chats::NotAllowedError, "policy forbids messaging" unless Chats.(a, b) conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c| c.kind = "direct" c.subject = about end # `create_or_find_by!` may have FOUND a conversation created a moment # ago by the other side — participants are ensured idempotently # either way (their own unique index makes this race-safe too). [a, b].each { || conversation.add_participant!() } conversation end |
.direct_key_for(pair, subject: nil) ⇒ Object
Deterministic identity for a direct pair (+ optional subject). GlobalID params already encode class + id, so “User 4” and “Organization 4” can never collide. Sorting makes it order-independent.
172 173 174 175 176 |
# File 'lib/chats/models/conversation.rb', line 172 def direct_key_for(pair, subject: nil) key = pair.map { || Chats.() }.sort.join("|") key += "|about:#{subject.to_global_id.to_param}" if subject key end |
.group!(creator, others, title: nil, about: nil) ⇒ Object
Create a group conversation. others excludes the creator (who joins as “owner”). Raises Chats::NotAllowedError when groups are disabled or the host ‘can_create_group` policy says no.
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 |
# File 'lib/chats/models/conversation.rb', line 151 def group!(creator, others, title: nil, about: nil) raise Chats::NotAllowedError, "group conversations are disabled" unless Chats.config.groups unless Chats.config.can_create_group.call(creator) raise Chats::NotAllowedError, "policy forbids creating groups" end others = Array(others) - [creator] raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2 transaction do conversation = create!(kind: "group", title: title, subject: about) conversation.add_participant!(creator, role: "owner") others.each { || conversation.add_participant!() } conversation end end |
.unread_counts_for(messager, conversations) ⇒ Object
Per-conversation unread counts for a messager over a set of conversations, in ONE grouped query — the inbox uses this to render row badges without N+1:
Chats::Conversation.unread_counts_for(user, conversations) # => { id => count }
182 183 184 185 186 187 188 189 |
# File 'lib/chats/models/conversation.rb', line 182 def unread_counts_for(, conversations) ids = Array(conversations).map { |c| c.is_a?(Conversation) ? c.id : c } return {} if ids.empty? unread_by().where(chats_conversations: { id: ids }) .group("chats_conversations.id") .count("chats_messages.id") end |
Instance Method Details
#add_participant!(messager, role: "member") ⇒ Object
Idempotent, race-safe membership. Re-adding someone who left re-joins them (their read state survives — by design, so history isn’t re-marked unread).
243 244 245 246 247 248 249 250 251 |
# File 'lib/chats/models/conversation.rb', line 243 def add_participant!(, role: "member") participant = participants.create_or_find_by!( messager_type: .class.polymorphic_name, messager_id: .id ) do |p| p.role = role end participant.update!(left_at: nil) if participant.left? participant end |
#direct? ⇒ Boolean
— Predicates & lookups ————————————————-
194 |
# File 'lib/chats/models/conversation.rb', line 194 def direct? = kind == "direct" |
#group? ⇒ Boolean
195 |
# File 'lib/chats/models/conversation.rb', line 195 def group? = kind == "group" |
#mark_read_by!(messager) ⇒ Object
273 274 275 |
# File 'lib/chats/models/conversation.rb', line 273 def mark_read_by!() participant_for()&.read! end |
#moderation_content_type ⇒ Object
337 338 339 |
# File 'lib/chats/models/conversation.rb', line 337 def moderation_content_type group? ? "group" : "conversation" end |
#moderation_label ⇒ Object
314 315 316 |
# File 'lib/chats/models/conversation.rb', line 314 def moderation_label "Chat conversation #{id}" end |
#moderation_snapshot(field) ⇒ Object
318 319 320 |
# File 'lib/chats/models/conversation.rb', line 318 def moderation_snapshot(field) title if field.to_s == "title" end |
#other_participants(messager) ⇒ Object
209 210 211 212 213 |
# File 'lib/chats/models/conversation.rb', line 209 def other_participants() participants.active.where.not( messager_type: .class.polymorphic_name, messager_id: .id ) end |
#participant?(messager) ⇒ Boolean
203 204 205 206 207 |
# File 'lib/chats/models/conversation.rb', line 203 def participant?() return false if .nil? participants.active.exists?(messager_type: .class.polymorphic_name, messager_id: .id) end |
#participant_for(messager) ⇒ Object
197 198 199 200 201 |
# File 'lib/chats/models/conversation.rb', line 197 def participant_for() return nil if .nil? participants.find_by(messager: ) end |
#post_system_message!(body) ⇒ Object
Post a message from your APP into the conversation — “Your ride was cancelled”, “Payment received” — rendered as a centered system note, not a bubble. This is the stable entry point notification systems (e.g. a Noticed delivery method) should call.
259 260 261 |
# File 'lib/chats/models/conversation.rb', line 259 def (body) .create!(kind: "system", body: body) end |
#recompute_last_message! ⇒ Object
:nodoc:
290 291 292 293 294 295 296 297 |
# File 'lib/chats/models/conversation.rb', line 290 def # :nodoc: latest = .order(created_at: :desc, id: :desc).first update_columns( last_message_at: latest&.created_at, last_message_id: latest&.id, updated_at: Time.current ) end |
#register_last_message!(message) ⇒ Object
Denormalized pointers the inbox sorts/previews by. Called from Chats::Message callbacks inside the message’s own transaction. ‘update_columns` on purpose: no validations/callbacks/broadcast loops —this is bookkeeping, not domain change. updated_at is bumped manually so fragment caches keyed on the conversation still invalidate.
282 283 284 285 286 287 288 |
# File 'lib/chats/models/conversation.rb', line 282 def () # :nodoc: update_columns( last_message_at: .created_at, last_message_id: .id, updated_at: Time.current ) end |
#removable_reported_field?(field) ⇒ Boolean
322 323 324 |
# File 'lib/chats/models/conversation.rb', line 322 def removable_reported_field?(field) field.to_s == "title" && title.present? end |
#remove_reported_field!(field) ⇒ Object
326 327 328 329 330 331 |
# File 'lib/chats/models/conversation.rb', line 326 def remove_reported_field!(field) return false unless field.to_s == "title" update!(title: nil) true end |
#report_visible_to?(viewer, field: nil) ⇒ Boolean
333 334 335 |
# File 'lib/chats/models/conversation.rb', line 333 def report_visible_to?(viewer, field: nil) participant?(viewer) end |
#reported_owner ⇒ Object
— Moderation contract (duck-typed, zero coupling) ————————
These methods make the conversation a well-behaved reportable the moment the HOST includes the moderate gem’s concern (‘Chats::Conversation. has_reportable_content :title`). They’re plain Ruby — defining them without moderate installed costs nothing. See README “Trust & Safety”.
306 307 308 309 310 311 312 |
# File 'lib/chats/models/conversation.rb', line 306 def reported_owner # The accountable human for a conversation: its owner (groups) or the # first participant (direct — moderation of direct threads usually # targets a specific *message*, but DSA tooling wants an owner). owner = participants.find_by(role: "owner") || participants.order(:created_at).first owner&. end |
#subject_label ⇒ Object
A short human label for the subject (“Madrid → Barcelona”, “Order #4221”), provided by the subject model via ‘chat_subject_label` (see Chats::ChatSubject). Nil when the conversation is about nothing.
232 233 234 235 236 |
# File 'lib/chats/models/conversation.rb', line 232 def subject_label return nil if subject.nil? subject.try(:chat_subject_label) || "#{subject.class.model_name.human} #{subject.id}" end |
#title_for(viewer) ⇒ Object
What this conversation is called from viewer‘s seat: a direct thread is named after the counterpart; a group after its title (or its members, when untitled).
218 219 220 221 222 223 224 225 226 227 |
# File 'lib/chats/models/conversation.rb', line 218 def title_for(viewer) if direct? other = other_participants(viewer).includes(:messager).first other ? Chats.display_name_for(other.) : I18n.t("chats.conversation.empty_title") else title.presence || participants.active.includes(:messager).limit(4).map do |p| Chats.display_name_for(p.) end.join(", ") end end |
#unread_count_for(messager) ⇒ Object
— Read state ———————————————————–
265 266 267 |
# File 'lib/chats/models/conversation.rb', line 265 def unread_count_for() participant_for()&.unread_count || 0 end |
#unread_for?(messager) ⇒ Boolean
269 270 271 |
# File 'lib/chats/models/conversation.rb', line 269 def unread_for?() unread_count_for().positive? end |