Class: Chats::Conversation

Inherits:
ApplicationRecord show all
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

Instance Method Summary collapse

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

Raises:

  • (ArgumentError)


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.can_message?(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 { |messager| conversation.add_participant!(messager) }
  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 { |messager| Chats.messager_key(messager) }.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 { |messager| conversation.add_participant!(messager) }
    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(messager, conversations)
  ids = Array(conversations).map { |c| c.is_a?(Conversation) ? c.id : c }
  return {} if ids.empty?

  unread_by(messager).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!(messager, role: "member")
  participant = participants.create_or_find_by!(
    messager_type: messager.class.polymorphic_name, messager_id: messager.id
  ) do |p|
    p.role = role
  end
  participant.update!(left_at: nil) if participant.left?
  participant
end

#direct?Boolean

— Predicates & lookups ————————————————-

Returns:

  • (Boolean)


194
# File 'lib/chats/models/conversation.rb', line 194

def direct? = kind == "direct"

#group?Boolean

Returns:

  • (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!(messager)
  participant_for(messager)&.read!
end

#moderation_content_typeObject



337
338
339
# File 'lib/chats/models/conversation.rb', line 337

def moderation_content_type
  group? ? "group" : "conversation"
end

#moderation_labelObject



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(messager)
  participants.active.where.not(
    messager_type: messager.class.polymorphic_name, messager_id: messager.id
  )
end

#participant?(messager) ⇒ Boolean

Returns:

  • (Boolean)


203
204
205
206
207
# File 'lib/chats/models/conversation.rb', line 203

def participant?(messager)
  return false if messager.nil?

  participants.active.exists?(messager_type: messager.class.polymorphic_name, messager_id: messager.id)
end

#participant_for(messager) ⇒ Object



197
198
199
200
201
# File 'lib/chats/models/conversation.rb', line 197

def participant_for(messager)
  return nil if messager.nil?

  participants.find_by(messager: 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 post_system_message!(body)
  messages.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 recompute_last_message! # :nodoc:
  latest = messages.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 register_last_message!(message) # :nodoc:
  update_columns(
    last_message_at: message.created_at,
    last_message_id: message.id,
    updated_at: Time.current
  )
end

#removable_reported_field?(field) ⇒ Boolean

Returns:

  • (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

Returns:

  • (Boolean)


333
334
335
# File 'lib/chats/models/conversation.rb', line 333

def report_visible_to?(viewer, field: nil)
  participant?(viewer)
end

#reported_ownerObject

— 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&.messager
end

#subject_labelObject

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.messager) : I18n.t("chats.conversation.empty_title")
  else
    title.presence || participants.active.includes(:messager).limit(4).map do |p|
      Chats.display_name_for(p.messager)
    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(messager)
  participant_for(messager)&.unread_count || 0
end

#unread_for?(messager) ⇒ Boolean

Returns:

  • (Boolean)


269
270
271
# File 'lib/chats/models/conversation.rb', line 269

def unread_for?(messager)
  unread_count_for(messager).positive?
end