chats 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +43 -0
- data/.simplecov +52 -0
- data/AGENTS.md +5 -0
- data/Appraisals +17 -0
- data/CHANGELOG.md +74 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/chats.css +818 -0
- data/app/controllers/chats/application_controller.rb +65 -0
- data/app/controllers/chats/conversations_controller.rb +198 -0
- data/app/controllers/chats/messages_controller.rb +118 -0
- data/app/controllers/chats/reactions_controller.rb +33 -0
- data/app/helpers/chats/engine_helper.rb +212 -0
- data/app/javascript/chats/composer_controller.js +258 -0
- data/app/javascript/chats/debounced_submit_controller.js +40 -0
- data/app/javascript/chats/thread_controller.js +855 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
- data/app/views/chats/conversations/_messages_page.html.erb +16 -0
- data/app/views/chats/conversations/_read_state.html.erb +11 -0
- data/app/views/chats/conversations/index.html.erb +54 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
- data/app/views/chats/conversations/show.html.erb +137 -0
- data/app/views/chats/messages/_composer.html.erb +67 -0
- data/app/views/chats/messages/_message.html.erb +158 -0
- data/app/views/chats/messages/create.turbo_stream.erb +6 -0
- data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
- data/app/views/chats/shared/_unread_badge.html.erb +6 -0
- data/config/importmap.rb +16 -0
- data/config/locales/en.yml +87 -0
- data/config/locales/es.yml +87 -0
- data/config/routes.rb +24 -0
- data/docs/PRD.md +254 -0
- data/docs/campfire_review.md +46 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/chats/broadcasts.rb +147 -0
- data/lib/chats/configuration.rb +286 -0
- data/lib/chats/engine.rb +146 -0
- data/lib/chats/errors.rb +20 -0
- data/lib/chats/macros.rb +28 -0
- data/lib/chats/models/application_record.rb +11 -0
- data/lib/chats/models/concerns/chat_subject.rb +35 -0
- data/lib/chats/models/concerns/messager.rb +102 -0
- data/lib/chats/models/conversation.rb +347 -0
- data/lib/chats/models/message.rb +323 -0
- data/lib/chats/models/participant.rb +151 -0
- data/lib/chats/models/reaction.rb +70 -0
- data/lib/chats/version.rb +5 -0
- data/lib/chats.rb +188 -0
- data/lib/generators/chats/install_generator.rb +62 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
- data/lib/generators/chats/templates/initializer.rb +138 -0
- data/lib/generators/chats/views_generator.rb +49 -0
- metadata +204 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# Included by `acts_as_messager`. Gives any model an inbox and the two
|
|
5
|
+
# verbs that make the whole gem read like plain English:
|
|
6
|
+
#
|
|
7
|
+
# alice.chat_with(bob) # find-or-create the DM
|
|
8
|
+
# alice.chat_with(bob, about: ride) # the DM about that ride
|
|
9
|
+
# alice.chat_with(bob, carol, title: "Trip") # a group
|
|
10
|
+
#
|
|
11
|
+
# alice.message!(bob, "hola!") # DM in one line
|
|
12
|
+
# alice.message!(conversation, "hola!") # or into a conversation
|
|
13
|
+
#
|
|
14
|
+
# alice.chats # her inbox, newest first
|
|
15
|
+
# alice.unread_chats_count # for the nav badge
|
|
16
|
+
module Messager
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
has_many :chat_participations,
|
|
21
|
+
class_name: "Chats::Participant",
|
|
22
|
+
as: :messager,
|
|
23
|
+
inverse_of: :messager,
|
|
24
|
+
dependent: :destroy
|
|
25
|
+
|
|
26
|
+
has_many :chat_conversations,
|
|
27
|
+
through: :chat_participations,
|
|
28
|
+
source: :conversation
|
|
29
|
+
|
|
30
|
+
# `dependent: :nullify`: when a messager account is destroyed, their
|
|
31
|
+
# messages stay as context for everyone else (sender shows as a
|
|
32
|
+
# localized "deleted account"), mirroring what every serious messaging
|
|
33
|
+
# product does. The sender-presence validation only runs on create, so
|
|
34
|
+
# orphaned rows remain valid.
|
|
35
|
+
has_many :chat_messages,
|
|
36
|
+
class_name: "Chats::Message",
|
|
37
|
+
as: :sender,
|
|
38
|
+
dependent: :nullify
|
|
39
|
+
|
|
40
|
+
Chats.register_messager(self)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Find-or-create a conversation with one or more other messagers.
|
|
44
|
+
# One other → the direct thread (per-pair, or per-pair-per-subject when
|
|
45
|
+
# `about:` is given). Several others → a group.
|
|
46
|
+
#
|
|
47
|
+
# Raises Chats::BlockedError / Chats::NotAllowedError — see
|
|
48
|
+
# Conversation.direct_between! / .group!.
|
|
49
|
+
def chat_with(*others, about: nil, title: nil)
|
|
50
|
+
others = others.flatten.compact
|
|
51
|
+
raise ArgumentError, "chat_with requires at least one other messager" if others.empty?
|
|
52
|
+
|
|
53
|
+
if others.size == 1
|
|
54
|
+
Chats::Conversation.direct_between!(self, others.first, about: about)
|
|
55
|
+
else
|
|
56
|
+
Chats::Conversation.group!(self, others, title: title, about: about)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Send a message — to a messager (resolving/creating the direct thread)
|
|
61
|
+
# or straight into a Chats::Conversation. Returns the Chats::Message.
|
|
62
|
+
#
|
|
63
|
+
# alice.message!(bob, "are you coming?")
|
|
64
|
+
# alice.message!(bob, "about the ride", about: ride)
|
|
65
|
+
# alice.message!(conversation, "hi all!", files: [photo])
|
|
66
|
+
def message!(target, body = nil, about: nil, files: [], reply_to: nil)
|
|
67
|
+
conversation =
|
|
68
|
+
case target
|
|
69
|
+
when Chats::Conversation then target
|
|
70
|
+
else chat_with(target, about: about)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
attributes = { sender: self, body: body, reply_to: reply_to }
|
|
74
|
+
attributes[:files] = files if files.present?
|
|
75
|
+
conversation.messages.create!(**attributes)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The inbox: every active conversation, blocked counterparts hidden,
|
|
79
|
+
# newest activity first. A relation — chain `.limit`, `.includes`, etc.
|
|
80
|
+
def chats
|
|
81
|
+
Chats::Conversation.inbox_for(self)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Number of conversations with unread messages (the WhatsApp-style nav
|
|
85
|
+
# badge counts conversations, not messages — 47 unread messages in one
|
|
86
|
+
# thread is still "1 thing to deal with").
|
|
87
|
+
def unread_chats_count
|
|
88
|
+
# reorder(nil): the inbox scope orders by a COALESCE expression, which
|
|
89
|
+
# PostgreSQL rejects inside a COUNT(DISTINCT …) aggregate.
|
|
90
|
+
chats.unread_by(self).reorder(nil).distinct.count
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def unread_chats?
|
|
94
|
+
chats.unread_by(self).reorder(nil).exists?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# This messager's participant row in +conversation+ (nil when not in it).
|
|
98
|
+
def chat_participation_in(conversation)
|
|
99
|
+
conversation.participant_for(self)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# A conversation: either a +direct+ 1:1 thread or a +group+. Optionally
|
|
5
|
+
# *about* a host record (the polymorphic +subject+ — a ride, an order, a
|
|
6
|
+
# listing), which is how marketplace-style apps attach a chat to a domain
|
|
7
|
+
# object.
|
|
8
|
+
#
|
|
9
|
+
# == Direct conversations & the +direct_key+
|
|
10
|
+
#
|
|
11
|
+
# Two people opening a DM with each other at the same instant must end up
|
|
12
|
+
# in the SAME conversation. We guarantee that with a deterministic
|
|
13
|
+
# +direct_key+ ("the sorted pair of participant keys, plus the subject key
|
|
14
|
+
# when the conversation is about something") backed by a UNIQUE index, and
|
|
15
|
+
# `create_or_find_by!` which turns the index violation into a find. See
|
|
16
|
+
# `.direct_between!`.
|
|
17
|
+
#
|
|
18
|
+
# The subject participates in the key on purpose: the same pair can have
|
|
19
|
+
# one thread per subject (per-listing threads, marketplace-style) AND one
|
|
20
|
+
# subjectless thread (classic DMs). The host picks the cardinality simply
|
|
21
|
+
# by passing or omitting `about:`.
|
|
22
|
+
#
|
|
23
|
+
# == Denormalization
|
|
24
|
+
#
|
|
25
|
+
# +last_message_at+ and +last_message_id+ exist so the inbox (the hottest
|
|
26
|
+
# query in any messaging product) is one indexed ORDER BY plus one
|
|
27
|
+
# `includes(:last_message)` — no MAX() subqueries, no N+1.
|
|
28
|
+
class Conversation < ApplicationRecord
|
|
29
|
+
self.table_name = "chats_conversations"
|
|
30
|
+
|
|
31
|
+
KINDS = %w[direct group].freeze
|
|
32
|
+
TITLE_MAX_LENGTH = 120
|
|
33
|
+
EPOCH = Time.utc(1970).freeze
|
|
34
|
+
|
|
35
|
+
belongs_to :subject, polymorphic: true, optional: true
|
|
36
|
+
# No DB foreign key on last_message_id (see migration): a circular
|
|
37
|
+
# conversations<->messages FK pair makes deletes order-dependent. The
|
|
38
|
+
# association is best-effort denormalization, never authority.
|
|
39
|
+
belongs_to :last_message, class_name: "Chats::Message", optional: true
|
|
40
|
+
|
|
41
|
+
has_many :participants,
|
|
42
|
+
class_name: "Chats::Participant",
|
|
43
|
+
inverse_of: :conversation,
|
|
44
|
+
dependent: :destroy
|
|
45
|
+
has_many :messages,
|
|
46
|
+
class_name: "Chats::Message",
|
|
47
|
+
inverse_of: :conversation,
|
|
48
|
+
dependent: :destroy
|
|
49
|
+
|
|
50
|
+
scope :direct, -> { where(kind: "direct") }
|
|
51
|
+
# `groups` (plural): `scope :group` would collide with ActiveRecord's own
|
|
52
|
+
# GROUP BY class method.
|
|
53
|
+
scope :groups, -> { where(kind: "group") }
|
|
54
|
+
scope :about, ->(subject) { where(subject: subject) }
|
|
55
|
+
scope :recent_first, lambda {
|
|
56
|
+
# COALESCE keeps freshly-created (still message-less) conversations
|
|
57
|
+
# sorted by creation time, portable across sqlite/postgres/mysql
|
|
58
|
+
# (no NULLS LAST, which sqlite/mysql spell differently).
|
|
59
|
+
order(Arel.sql("COALESCE(chats_conversations.last_message_at, chats_conversations.created_at) DESC"))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# A messager's inbox: every conversation they're an active participant of
|
|
63
|
+
# (not left), minus direct threads with someone they're blocked with,
|
|
64
|
+
# newest activity first. THE hot query — keep it composable and indexed.
|
|
65
|
+
scope :inbox_for, lambda { |messager|
|
|
66
|
+
joins(:participants)
|
|
67
|
+
.where(chats_participants: { left_at: nil })
|
|
68
|
+
.where(chats_participants: { messager_type: messager.class.polymorphic_name, messager_id: messager.id })
|
|
69
|
+
.excluding_blocked_for(messager)
|
|
70
|
+
.recent_first
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Hide direct conversations whose counterpart is blocked (either
|
|
74
|
+
# direction — `Chats.blocked_ids_for` is bidirectional by contract).
|
|
75
|
+
# Group conversations are NOT hidden: industry standard is that blocking
|
|
76
|
+
# someone doesn't eject you from shared group spaces. Data is never
|
|
77
|
+
# deleted — lift the block and the thread reappears.
|
|
78
|
+
scope :excluding_blocked_for, lambda { |messager|
|
|
79
|
+
blocked_ids = Chats.blocked_ids_for(messager)
|
|
80
|
+
next all if blocked_ids.respond_to?(:empty?) && blocked_ids.empty?
|
|
81
|
+
|
|
82
|
+
blocked_seats = Chats::Participant.select(:conversation_id).where(
|
|
83
|
+
messager_type: messager.class.polymorphic_name, messager_id: blocked_ids
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
where.not(id: direct.where(id: blocked_seats))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Conversations holding messages the messager hasn't read yet (excluding
|
|
90
|
+
# their own messages and tombstones). Compose with `inbox_for`:
|
|
91
|
+
# Chats::Conversation.inbox_for(user).unread_by(user).distinct.count
|
|
92
|
+
scope :unread_by, lambda { |messager|
|
|
93
|
+
joins(:participants, :messages)
|
|
94
|
+
.where(chats_participants: { messager_type: messager.class.polymorphic_name, messager_id: messager.id })
|
|
95
|
+
.where(chats_messages: { deleted_at: nil })
|
|
96
|
+
.where("chats_messages.created_at > COALESCE(chats_participants.last_read_at, ?)", EPOCH)
|
|
97
|
+
# "Not sent by me" — with an explicit IS NULL leg: system messages
|
|
98
|
+
# have a NULL sender, and in SQL's three-valued logic a bare
|
|
99
|
+
# NOT(sender_type = X AND …) evaluates to NULL (not TRUE) for them,
|
|
100
|
+
# silently dropping system messages from unread counts.
|
|
101
|
+
.where(
|
|
102
|
+
"chats_messages.sender_type IS NULL OR NOT (chats_messages.sender_type = ? AND chats_messages.sender_id = ?)",
|
|
103
|
+
messager.class.polymorphic_name, messager.id.to_s
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
validates :kind, inclusion: { in: KINDS }
|
|
108
|
+
validates :title, length: { maximum: TITLE_MAX_LENGTH }, allow_blank: true
|
|
109
|
+
# direct_key uniqueness is enforced by the DB unique index ONLY — on
|
|
110
|
+
# purpose, not an oversight. `create_or_find_by!` (the race-safe
|
|
111
|
+
# find-or-create in .direct_between!) works by attempting the INSERT and
|
|
112
|
+
# converting the index violation into a find; a model-level uniqueness
|
|
113
|
+
# validation would fire first, raise RecordInvalid, and break the whole
|
|
114
|
+
# mechanism. https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-create_or_find_by
|
|
115
|
+
validate :groups_must_be_enabled, if: :group?
|
|
116
|
+
|
|
117
|
+
# --- Finding & creating ---------------------------------------------------
|
|
118
|
+
|
|
119
|
+
class << self
|
|
120
|
+
# The direct conversation between +a+ and +b+ (about +subject+, when
|
|
121
|
+
# given), or nil. Read-only twin of `.direct_between!`.
|
|
122
|
+
def direct_between(a, b, about: nil)
|
|
123
|
+
find_by(direct_key: direct_key_for([a, b], subject: about))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Find-or-create the direct conversation between two messagers,
|
|
127
|
+
# race-safely (see class comment). Raises:
|
|
128
|
+
# Chats::BlockedError if the pair is blocked (either direction)
|
|
129
|
+
# Chats::NotAllowedError if the host `can_message` policy says no
|
|
130
|
+
def direct_between!(a, b, about: nil)
|
|
131
|
+
raise ArgumentError, "both participants are required" if a.nil? || b.nil?
|
|
132
|
+
raise ArgumentError, "can't open a conversation with yourself" if a == b
|
|
133
|
+
raise Chats::BlockedError, "messagers are blocked" if Chats.blocked_between?(a, b)
|
|
134
|
+
raise Chats::NotAllowedError, "policy forbids messaging" unless Chats.can_message?(a, b)
|
|
135
|
+
|
|
136
|
+
conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c|
|
|
137
|
+
c.kind = "direct"
|
|
138
|
+
c.subject = about
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# `create_or_find_by!` may have FOUND a conversation created a moment
|
|
142
|
+
# ago by the other side — participants are ensured idempotently
|
|
143
|
+
# either way (their own unique index makes this race-safe too).
|
|
144
|
+
[a, b].each { |messager| conversation.add_participant!(messager) }
|
|
145
|
+
conversation
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Create a group conversation. +others+ excludes the creator (who joins
|
|
149
|
+
# as "owner"). Raises Chats::NotAllowedError when groups are disabled
|
|
150
|
+
# or the host `can_create_group` policy says no.
|
|
151
|
+
def group!(creator, others, title: nil, about: nil)
|
|
152
|
+
raise Chats::NotAllowedError, "group conversations are disabled" unless Chats.config.groups
|
|
153
|
+
unless Chats.config.can_create_group.call(creator)
|
|
154
|
+
raise Chats::NotAllowedError,
|
|
155
|
+
"policy forbids creating groups"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
others = Array(others) - [creator]
|
|
159
|
+
raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
|
|
160
|
+
|
|
161
|
+
transaction do
|
|
162
|
+
conversation = create!(kind: "group", title: title, subject: about)
|
|
163
|
+
conversation.add_participant!(creator, role: "owner")
|
|
164
|
+
others.each { |messager| conversation.add_participant!(messager) }
|
|
165
|
+
conversation
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Deterministic identity for a direct pair (+ optional subject).
|
|
170
|
+
# GlobalID params already encode class + id, so "User 4" and
|
|
171
|
+
# "Organization 4" can never collide. Sorting makes it order-independent.
|
|
172
|
+
def direct_key_for(pair, subject: nil)
|
|
173
|
+
key = pair.map { |messager| Chats.messager_key(messager) }.sort.join("|")
|
|
174
|
+
key += "|about:#{subject.to_global_id.to_param}" if subject
|
|
175
|
+
key
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Per-conversation unread counts for a messager over a set of
|
|
179
|
+
# conversations, in ONE grouped query — the inbox uses this to render
|
|
180
|
+
# row badges without N+1:
|
|
181
|
+
# Chats::Conversation.unread_counts_for(user, conversations) # => { id => count }
|
|
182
|
+
def unread_counts_for(messager, conversations)
|
|
183
|
+
ids = Array(conversations).map { |c| c.is_a?(Conversation) ? c.id : c }
|
|
184
|
+
return {} if ids.empty?
|
|
185
|
+
|
|
186
|
+
unread_by(messager).where(chats_conversations: { id: ids })
|
|
187
|
+
.group("chats_conversations.id")
|
|
188
|
+
.count("chats_messages.id")
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# --- Predicates & lookups -------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def direct? = kind == "direct"
|
|
195
|
+
def group? = kind == "group"
|
|
196
|
+
|
|
197
|
+
def participant_for(messager)
|
|
198
|
+
return nil if messager.nil?
|
|
199
|
+
|
|
200
|
+
participants.find_by(messager: messager)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def participant?(messager)
|
|
204
|
+
return false if messager.nil?
|
|
205
|
+
|
|
206
|
+
participants.active.exists?(messager_type: messager.class.polymorphic_name, messager_id: messager.id)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def other_participants(messager)
|
|
210
|
+
participants.active.where.not(
|
|
211
|
+
messager_type: messager.class.polymorphic_name, messager_id: messager.id
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# What this conversation is called from +viewer+'s seat: a direct thread
|
|
216
|
+
# is named after the counterpart; a group after its title (or its
|
|
217
|
+
# members, when untitled).
|
|
218
|
+
def title_for(viewer)
|
|
219
|
+
if direct?
|
|
220
|
+
other = other_participants(viewer).includes(:messager).first
|
|
221
|
+
other ? Chats.display_name_for(other.messager) : I18n.t("chats.conversation.empty_title")
|
|
222
|
+
else
|
|
223
|
+
title.presence || participants.active.includes(:messager).limit(4).map do |p|
|
|
224
|
+
Chats.display_name_for(p.messager)
|
|
225
|
+
end.join(", ")
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# A short human label for the subject ("Madrid → Barcelona", "Order
|
|
230
|
+
# #4221"), provided by the subject model via `chat_subject_label`
|
|
231
|
+
# (see Chats::ChatSubject). Nil when the conversation is about nothing.
|
|
232
|
+
def subject_label
|
|
233
|
+
return nil if subject.nil?
|
|
234
|
+
|
|
235
|
+
subject.try(:chat_subject_label) || "#{subject.class.model_name.human} #{subject.id}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# --- Membership -----------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
# Idempotent, race-safe membership. Re-adding someone who left re-joins
|
|
241
|
+
# them (their read state survives — by design, so history isn't re-marked
|
|
242
|
+
# unread).
|
|
243
|
+
def add_participant!(messager, role: "member")
|
|
244
|
+
participant = participants.create_or_find_by!(
|
|
245
|
+
messager_type: messager.class.polymorphic_name, messager_id: messager.id
|
|
246
|
+
) do |p|
|
|
247
|
+
p.role = role
|
|
248
|
+
end
|
|
249
|
+
participant.update!(left_at: nil) if participant.left?
|
|
250
|
+
participant
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# --- Messaging ------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
# Post a message from your APP into the conversation — "Your ride was
|
|
256
|
+
# cancelled", "Payment received" — rendered as a centered system note,
|
|
257
|
+
# not a bubble. This is the stable entry point notification systems
|
|
258
|
+
# (e.g. a Noticed delivery method) should call.
|
|
259
|
+
def post_system_message!(body)
|
|
260
|
+
messages.create!(kind: "system", body: body)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# --- Read state -----------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def unread_count_for(messager)
|
|
266
|
+
participant_for(messager)&.unread_count || 0
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def unread_for?(messager)
|
|
270
|
+
unread_count_for(messager).positive?
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def mark_read_by!(messager)
|
|
274
|
+
participant_for(messager)&.read!
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Denormalized pointers the inbox sorts/previews by. Called from
|
|
278
|
+
# Chats::Message callbacks inside the message's own transaction.
|
|
279
|
+
# `update_columns` on purpose: no validations/callbacks/broadcast loops —
|
|
280
|
+
# this is bookkeeping, not domain change. updated_at is bumped manually
|
|
281
|
+
# so fragment caches keyed on the conversation still invalidate.
|
|
282
|
+
def register_last_message!(message) # :nodoc:
|
|
283
|
+
update_columns(
|
|
284
|
+
last_message_at: message.created_at,
|
|
285
|
+
last_message_id: message.id,
|
|
286
|
+
updated_at: Time.current
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def recompute_last_message! # :nodoc:
|
|
291
|
+
latest = messages.order(created_at: :desc, id: :desc).first
|
|
292
|
+
update_columns(
|
|
293
|
+
last_message_at: latest&.created_at,
|
|
294
|
+
last_message_id: latest&.id,
|
|
295
|
+
updated_at: Time.current
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# --- Moderation contract (duck-typed, zero coupling) ------------------------
|
|
300
|
+
#
|
|
301
|
+
# These methods make the conversation a well-behaved reportable the moment
|
|
302
|
+
# the HOST includes the moderate gem's concern (`Chats::Conversation.
|
|
303
|
+
# has_reportable_content :title`). They're plain Ruby — defining them
|
|
304
|
+
# without moderate installed costs nothing. See README "Trust & Safety".
|
|
305
|
+
|
|
306
|
+
def reported_owner
|
|
307
|
+
# The accountable human for a conversation: its owner (groups) or the
|
|
308
|
+
# first participant (direct — moderation of direct threads usually
|
|
309
|
+
# targets a specific *message*, but DSA tooling wants an owner).
|
|
310
|
+
owner = participants.find_by(role: "owner") || participants.order(:created_at).first
|
|
311
|
+
owner&.messager
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def moderation_label
|
|
315
|
+
"Chat conversation #{id}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def moderation_snapshot(field)
|
|
319
|
+
title if field.to_s == "title"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def removable_reported_field?(field)
|
|
323
|
+
field.to_s == "title" && title.present?
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def remove_reported_field!(field)
|
|
327
|
+
return false unless field.to_s == "title"
|
|
328
|
+
|
|
329
|
+
update!(title: nil)
|
|
330
|
+
true
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def report_visible_to?(viewer, field: nil)
|
|
334
|
+
participant?(viewer)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def moderation_content_type
|
|
338
|
+
group? ? "group" : "conversation"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
private
|
|
342
|
+
|
|
343
|
+
def groups_must_be_enabled
|
|
344
|
+
errors.add(:kind, :groups_disabled) unless Chats.config.groups
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|