Class: Chats::Message

Inherits:
ApplicationRecord show all
Defined in:
lib/chats/models/message.rb

Overview

A message in a conversation. Two kinds:

"text"    a human message from a +sender+ (any acts_as_messager model)
"system"  posted by the HOST APP via Conversation#post_system_message!
          ("Your ride was cancelled") — no sender, centered rendering

Soft deletion (“delete for everyone”)

With ‘config.deletion = :soft` (the default), deleting clears the body, purges attachments and stamps deleted_at — the row stays as a tombstone (“Message deleted”) exactly like WhatsApp/Telegram, which keeps thread continuity AND leaves an auditable trail for Trust & Safety. Note that moderation evidence is still safe: the moderate gem snapshots reported content AT REPORT TIME, so removing the body later never destroys the evidence a moderator needs.

Real-time

All Turbo Stream fan-out lives in Chats::Broadcasts (one place to read the whole live behavior). Broadcasts run in ‘after_*_commit` callbacks —never inside the transaction — and use the `_later` job variants so rendering never blocks the request.

Constant Summary collapse

KINDS =
%w[text system].freeze

Instance Method Summary collapse

Instance Method Details

#attachments?Boolean

Returns:

  • (Boolean)


157
158
159
# File 'lib/chats/models/message.rb', line 157

def attachments?
  respond_to?(:files) && files.attached?
end

#deleted?Boolean

Returns:

  • (Boolean)


104
# File 'lib/chats/models/message.rb', line 104

def deleted? = deleted_at.present?

#edit!(new_body) ⇒ Object

Edit the body (sender-only — controllers enforce who; the model enforces that editing is enabled). Stamps edited_at so the UI can show “edited”, and broadcasts the replacement bubble.



132
133
134
135
136
137
# File 'lib/chats/models/message.rb', line 132

def edit!(new_body)
  raise Chats::NotAllowedError, "editing is disabled" unless Chats.config.editing
  raise Chats::NotAllowedError, "can't edit a deleted message" if deleted?

  update!(body: new_body, edited_at: Time.current)
end

#edited?Boolean

Returns:

  • (Boolean)


105
# File 'lib/chats/models/message.rb', line 105

def edited? = edited_at.present?

#moderation_content_typeObject



203
204
205
# File 'lib/chats/models/message.rb', line 203

def moderation_content_type
  "message"
end

#moderation_field_changed_for_commit?(field) ⇒ Boolean

Returns:

  • (Boolean)


220
221
222
223
224
225
226
227
228
229
230
# File 'lib/chats/models/message.rb', line 220

def moderation_field_changed_for_commit?(field)
  if field.to_s == "files" && respond_to?(:files)
    # Attachments don't have a column; detect "files changed in this
    # commit" via the attachment records created in this transaction.
    files.attachments.any?(&:previously_new_record?)
  elsif respond_to?(:saved_change_to_attribute?)
    saved_change_to_attribute?(field)
  else
    true
  end
end

#moderation_field_value(field) ⇒ Object

Moderate::ContentFilterable seam: when the host declares ‘moderates :files, mode: :flag, with: :some_image_adapter`, the default `public_send(:files)` would hand the adapter an ActiveStorage proxy. Most image adapters (like a custo ImageReviewAdapter, or an AWS Rekognition adapter fed via ClassifyJob) don’t read the value anyway —they re-fetch the blob — but we make the value meaningful and change-detection correct regardless.



214
215
216
217
218
# File 'lib/chats/models/message.rb', line 214

def moderation_field_value(field)
  return files if field.to_s == "files" && respond_to?(:files)

  public_send(field)
end

#moderation_labelObject



174
175
176
# File 'lib/chats/models/message.rb', line 174

def moderation_label
  "Chat message #{id}"
end

#moderation_snapshot(field) ⇒ Object



178
179
180
# File 'lib/chats/models/message.rb', line 178

def moderation_snapshot(field)
  body if field.to_s == "body"
end

#read_by?(messager) ⇒ Boolean

Whether messager has read this message, derived from their read horizon (see Chats::Participant for why there’s no receipts table).

Returns:

  • (Boolean)


124
125
126
127
# File 'lib/chats/models/message.rb', line 124

def read_by?(messager)
  participant = conversation.participant_for(messager)
  participant&.last_read_at.present? && participant.last_read_at >= created_at
end

#removable_reported_field?(field) ⇒ Boolean

Returns:

  • (Boolean)


182
183
184
# File 'lib/chats/models/message.rb', line 182

def removable_reported_field?(field)
  field.to_s == "body" && body.present? && !deleted?
end

#remove_reported_field!(field) ⇒ Object

A moderator removing a reported message body = the soft-delete tombstone path, so the thread shows “Message deleted” instead of a hole, and attachments are purged with it.



189
190
191
192
193
# File 'lib/chats/models/message.rb', line 189

def remove_reported_field!(field)
  return false unless field.to_s == "body"

  soft_delete!
end

#report_visible_to?(viewer, field: nil) ⇒ Boolean

Only people in the conversation may report a message (a message isn’t public content), and you can’t report your own.

Returns:

  • (Boolean)


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

def report_visible_to?(viewer, field: nil)
  return false if viewer.nil? || sent_by?(viewer)

  conversation.participant?(viewer)
end

#reported_ownerObject

— Moderation contract (duck-typed, zero coupling) ————————

Plain-Ruby methods that make a message a first-class citizen of the moderate gem the moment the HOST wires it up (see README “Trust & Safety”): they satisfy Moderate::Reportable’s contract (owner, label, snapshot, removal, visibility) and Moderate::ContentFilterable’s field seams for attachment filtering. Without moderate installed they’re inert and cost nothing.



170
171
172
# File 'lib/chats/models/message.rb', line 170

def reported_owner
  sender
end

#sender_keyObject



107
108
109
# File 'lib/chats/models/message.rb', line 107

def sender_key
  sender && Chats.messager_key(sender)
end

#sent_by?(messager) ⇒ Boolean

Returns:

  • (Boolean)


111
112
113
# File 'lib/chats/models/message.rb', line 111

def sent_by?(messager)
  sender.present? && sender == messager
end

#soft_delete!Object

Delete according to ‘config.deletion` (see class comment). Returns false when deletion is disabled.



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/chats/models/message.rb', line 141

def soft_delete!
  case Chats.config.deletion
  when :soft
    transaction do
      files.purge_later if respond_to?(:files) && files.attached?
      update!(body: nil, deleted_at: Time.current)
    end
    true
  when :hard
    destroy!
    true
  else
    false
  end
end

#system?Boolean

Returns:

  • (Boolean)


102
# File 'lib/chats/models/message.rb', line 102

def system? = kind == "system"

#text?Boolean

Returns:

  • (Boolean)


103
# File 'lib/chats/models/message.rb', line 103

def text? = kind == "text"

#visible_bodyObject

The body as the UI should show it (tombstones render a localized “Message deleted” placeholder straight from the view, not from here —this just guards against showing stale bodies by accident).



118
119
120
# File 'lib/chats/models/message.rb', line 118

def visible_body
  deleted? ? nil : body
end