chats 0.1.1 → 0.3.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +233 -0
  3. data/README.md +214 -28
  4. data/app/assets/stylesheets/chats.css +86 -0
  5. data/app/controllers/chats/conversations_controller.rb +39 -42
  6. data/app/controllers/chats/messages_controller.rb +34 -0
  7. data/app/controllers/chats/reactions_controller.rb +14 -0
  8. data/app/helpers/chats/engine_helper.rb +113 -2
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
  11. data/app/views/chats/conversations/_group.html.erb +41 -0
  12. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  13. data/app/views/chats/conversations/index.html.erb +39 -9
  14. data/app/views/chats/conversations/show.html.erb +48 -3
  15. data/app/views/chats/messages/_composer.html.erb +4 -0
  16. data/app/views/chats/messages/_message.html.erb +29 -8
  17. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  18. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  19. data/config/importmap.rb +2 -1
  20. data/config/locales/en.yml +13 -0
  21. data/config/locales/es.yml +13 -0
  22. data/context7.json +4 -0
  23. data/docs/PRD.md +1 -1
  24. data/docs/campfire_review.md +1 -1
  25. data/gemfiles/rails_7.1.gemfile +1 -0
  26. data/gemfiles/rails_7.2.gemfile +1 -0
  27. data/gemfiles/rails_8.1.gemfile +1 -0
  28. data/lib/chats/configuration.rb +79 -1
  29. data/lib/chats/engine.rb +29 -7
  30. data/lib/chats/errors.rb +16 -0
  31. data/lib/chats/inbox.rb +303 -0
  32. data/lib/chats/inbox_group.rb +75 -0
  33. data/lib/chats/macros.rb +33 -1
  34. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  35. data/lib/chats/models/concerns/messager.rb +99 -2
  36. data/lib/chats/models/conversation.rb +78 -7
  37. data/lib/chats/models/message.rb +67 -3
  38. data/lib/chats/models/participant.rb +59 -0
  39. data/lib/chats/models/reaction.rb +5 -0
  40. data/lib/chats/subscribers.rb +156 -0
  41. data/lib/chats/version.rb +1 -1
  42. data/lib/chats.rb +122 -15
  43. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  44. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  45. data/lib/generators/chats/templates/initializer.rb +106 -14
  46. data/lib/generators/chats/upgrade_generator.rb +48 -0
  47. metadata +13 -2
data/lib/chats/engine.rb CHANGED
@@ -30,7 +30,7 @@ module Chats
30
30
  CHATS_LIB = File.expand_path("chats", LIB_ROOT)
31
31
 
32
32
  ZEITWERK_IGNORED = %w[
33
- version.rb errors.rb configuration.rb engine.rb macros.rb
33
+ version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb
34
34
  ].freeze
35
35
 
36
36
  initializer "chats.autoload", before: :set_autoload_paths do
@@ -64,6 +64,13 @@ module Chats
64
64
  end
65
65
  end
66
66
 
67
+ # Hand the gem's deprecator to the app, so `config.active_support.
68
+ # deprecation` (and `deprecators.silence`) govern chats' own deprecation
69
+ # warnings like any other framework's.
70
+ initializer "chats.deprecator" do |app|
71
+ app.deprecators[:chats] = Chats.deprecator if app.respond_to?(:deprecators)
72
+ end
73
+
67
74
  # Expose `acts_as_messager` / `acts_as_chat_subject` on every AR model.
68
75
  initializer "chats.active_record" do
69
76
  ActiveSupport.on_load(:active_record) do
@@ -71,11 +78,20 @@ module Chats
71
78
  end
72
79
  end
73
80
 
74
- # Ship the gem's locale files (en, es). Host locale files with the same
75
- # keys override these automatically (I18n's load order puts the app last).
76
- initializer "chats.locales" do |app|
77
- app.config.i18n.load_path += Dir[root.join("config", "locales", "**", "*.{rb,yml}").to_s]
78
- end
81
+ # The gem's locale files (en, es) ship through Rails::Engine's own
82
+ # :add_locales initializer, which picks up every engine's config/locales
83
+ # automatically and deliberately NOT through a manual
84
+ # `app.config.i18n.load_path +=` on top of it.
85
+ #
86
+ # That append is not merely redundant, it inverts the contract: railtie
87
+ # paths are unshifted ahead of everything in load_path, so an appended
88
+ # copy lands AFTER the host's own locales and silently overrides them. A
89
+ # host rewording `chats.flashes.blocked` in its own es.yml would keep
90
+ # reading ours, with no error and nothing to see.
91
+ #
92
+ # Gem first, host last. `clickwrap` carries the same note; `support_desk`
93
+ # shipped the bug and measured it (its file sat in load_path 14 times and
94
+ # the host's override lost).
79
95
 
80
96
  # NOTE: the host-facing helpers (`chat_button_to`, `chats_unread_badge`, …)
81
97
  # are exposed to ActionView from the BOTTOM of engine_helper.rb itself
@@ -90,7 +106,8 @@ module Chats
90
106
 
91
107
  # -------------------------------------------------------------------------
92
108
  # JavaScript: the engine ships tiny Stimulus controllers (thread, composer,
93
- # debounced-submit) with NO build step, pinned for importmap-rails hosts.
109
+ # debounced-submit, refresh-inbox) with NO build step, pinned for
110
+ # importmap-rails hosts.
94
111
  #
95
112
  # The pin keys live under "controllers/chats/..." ON PURPOSE: the stock
96
113
  # Rails `app/javascript/controllers/index.js` calls
@@ -125,6 +142,11 @@ module Chats
125
142
  if app.config.respond_to?(:assets)
126
143
  app.config.assets.paths << root.join("app/javascript")
127
144
  app.config.assets.paths << root.join("app/assets/stylesheets")
145
+
146
+ # Propshaft serves anything on the load path; Sprockets serves only
147
+ # what is on the precompile list, so a Sprockets host 404s the
148
+ # stylesheet without this line.
149
+ app.config.assets.precompile << "chats.css" if app.config.assets.respond_to?(:precompile)
128
150
  end
129
151
  end
130
152
 
data/lib/chats/errors.rb CHANGED
@@ -17,4 +17,20 @@ module Chats
17
17
  # Raised when the host `can_message` policy (or a feature flag like
18
18
  # `config.groups = false`) forbids the attempted action.
19
19
  class NotAllowedError < Error; end
20
+
21
+ # Raised when a conversation's SUBJECT has locked it (see
22
+ # Chats::ChatSubject#chat_locked?) and something tries to write to it
23
+ # anyway — send, edit, delete, react. A subclass of NotAllowedError on
24
+ # purpose: a host that already rescues NotAllowedError keeps working, and
25
+ # one that wants to show the lock notice specifically can rescue this.
26
+ # System messages are never refused: the app must always be able to say
27
+ # "this was closed" in the thread it just closed.
28
+ class LockedError < NotAllowedError
29
+ attr_reader :conversation
30
+
31
+ def initialize(message = nil, conversation: nil)
32
+ @conversation = conversation
33
+ super(message || conversation&.locked_notice || "this conversation is closed")
34
+ end
35
+ end
20
36
  end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # THE inbox query, in one object: load a viewer's recent conversations,
5
+ # optionally filter them, and fold the ones that belong to a stacked
6
+ # counterpart into Chats::InboxGroup rows.
7
+ #
8
+ # Chats::Inbox.for(alice) # rows, newest activity first
9
+ # Chats::Inbox.for(alice, query: "madrid") # the search box
10
+ # Chats::Inbox.for(alice, with: support_desk) # one stack's contents
11
+ #
12
+ # Rows are `Chats::Conversation | Chats::InboxGroup`, sorted by last
13
+ # activity descending. Grouping happens HERE and nowhere else.
14
+ #
15
+ # == Why `inbox_limit` bounds ROWS, not conversations
16
+ #
17
+ # A stacked counterpart can hold hundreds of threads. Limiting the raw
18
+ # conversation query first would let a busy support desk EVICT everything
19
+ # else from the inbox — 200 desk threads and not one message from a friend.
20
+ # So the two populations are queried separately: ordinary conversations get
21
+ # the limit, stacked ones get their own bounded window, and the limit is
22
+ # applied again to the ROWS that come out. A stack's numbers
23
+ # (`open_count`, `unread_count`) are then GLOBAL, read with two indexed
24
+ # aggregates per stack — never per conversation, and never by loading the
25
+ # stack to count it.
26
+ class Inbox
27
+ include Enumerable
28
+
29
+ attr_reader :viewer, :query, :with
30
+
31
+ class << self
32
+ # The inbox for +viewer+. Returns a Chats::Inbox, which enumerates its
33
+ # rows (`to_a` for a plain Array).
34
+ def for(viewer, query: nil, with: nil)
35
+ new(viewer, query: query, with: with)
36
+ end
37
+ end
38
+
39
+ def initialize(viewer, query: nil, with: nil)
40
+ @viewer = viewer
41
+ @query = query.to_s.strip.presence
42
+ @with = with
43
+ end
44
+
45
+ # Conversation | InboxGroup rows, newest activity first, at most
46
+ # `config.inbox_limit` of them.
47
+ def rows
48
+ @rows ||= build_rows
49
+ end
50
+
51
+ # Yield each row (Enumerable gives `map`, `select`, `find`, … from here).
52
+ def each(&)
53
+ rows.each(&)
54
+ end
55
+
56
+ # The rows as a plain Array.
57
+ def to_a
58
+ rows
59
+ end
60
+
61
+ # How many rows the inbox has.
62
+ def size
63
+ rows.size
64
+ end
65
+
66
+ # Whether the inbox has any rows at all.
67
+ def any?
68
+ rows.any?
69
+ end
70
+
71
+ # Whether the inbox has no rows.
72
+ def empty?
73
+ rows.empty?
74
+ end
75
+
76
+ # The conversations behind the rows, stacked threads included. Stays an
77
+ # ActiveRecord::Relation (already loaded) in the simple case — no search,
78
+ # nothing stacked — so an inbox ejected under 0.1.x can still chain
79
+ # `.where` or hand it to a paginator. Becomes an Array once rows had to
80
+ # be assembled in Ruby.
81
+ def conversations
82
+ rows
83
+ @flat
84
+ end
85
+
86
+ # { conversation_id => unread message count } — one grouped query for
87
+ # every loaded conversation, which is what the row badges read.
88
+ def unread_counts
89
+ @unread_counts ||= Chats::Conversation.unread_counts_for(viewer, conversations)
90
+ end
91
+
92
+ # Unread messages in one conversation, from the grouped query above.
93
+ def unread_count_for(conversation)
94
+ unread_counts.fetch(conversation.id, 0)
95
+ end
96
+
97
+ # The stack-aware badge number: how many inbox ROWS carry unread content
98
+ # (a stack of five unread threads is still one thing to deal with).
99
+ # `Messager#unread_chats_count` is the unstacked count and is unchanged.
100
+ def unread_count
101
+ rows.count do |row|
102
+ row.is_a?(Chats::InboxGroup) ? row.unread? : unread_count_for(row).positive?
103
+ end
104
+ end
105
+
106
+ # True when scoped to one counterpart (`?with=`): a stack's contents,
107
+ # which are listed individually rather than re-stacked.
108
+ def filtered?
109
+ with.present?
110
+ end
111
+
112
+ private
113
+
114
+ def limit
115
+ Chats.config.inbox_limit
116
+ end
117
+
118
+ # Memoized so `config.inbox_scope` is consulted ONCE per inbox for the
119
+ # row query, however many legs it is split into (the stack aggregates
120
+ # apply it separately, to their own relation).
121
+ def base_relation
122
+ @base_relation ||= begin
123
+ relation = Chats::Conversation.inbox_for(viewer)
124
+ .includes(:last_message, :subject, participants: :messager)
125
+ Chats.config.inbox_scope.call(relation, viewer) || relation
126
+ end
127
+ end
128
+
129
+ # The polymorphic types worth splitting out. Empty when nothing stacks
130
+ # (an ordinary app pays nothing) and when the inbox is already filtered
131
+ # to one counterpart.
132
+ def grouped_types
133
+ @grouped_types ||= filtered? ? [] : Chats.grouped_messager_types
134
+ end
135
+
136
+ # Seats held by a stacked messager — never the viewer's own seat, so a
137
+ # stacked messager's OWN inbox stays flat.
138
+ def stacked_seats
139
+ Chats::Participant.select(:conversation_id)
140
+ .where(messager_type: grouped_types)
141
+ .where.not(messager_type: viewer.class.polymorphic_name, messager_id: viewer.id)
142
+ end
143
+
144
+ def load_conversations
145
+ if filtered?
146
+ @ungrouped_relation = filter_to_counterpart(base_relation).limit(limit)
147
+ @stacked = []
148
+ elsif grouped_types.empty?
149
+ @ungrouped_relation = base_relation.limit(limit)
150
+ @stacked = []
151
+ else
152
+ stacked = Chats::Conversation.direct.where(id: stacked_seats)
153
+ @ungrouped_relation = base_relation.where.not(id: stacked).limit(limit)
154
+ # Ordered by recency and limited like the other leg, which also makes
155
+ # the FIRST conversation of each counterpart that counterpart's
156
+ # freshest — that's the one the stacked row previews.
157
+ @stacked = apply_search(base_relation.direct.where(id: stacked_seats).limit(limit))
158
+ end
159
+
160
+ @ungrouped = apply_search(@ungrouped_relation)
161
+ # The relation itself when nothing had to be assembled in Ruby (it is
162
+ # loaded, so iterating it costs nothing extra); the flat Array otherwise.
163
+ @flat = @stacked.empty? && query.nil? ? @ungrouped_relation : @ungrouped + @stacked
164
+ end
165
+
166
+ # Only the direct threads shared with one counterpart. Direct only, by
167
+ # design: a group that happens to include the desk is not part of the
168
+ # desk's stack.
169
+ def filter_to_counterpart(relation)
170
+ seats = Chats::Participant.select(:conversation_id).where(
171
+ messager_type: with.class.polymorphic_name, messager_id: with.id
172
+ )
173
+ relation.direct.where(id: seats)
174
+ end
175
+
176
+ def build_rows
177
+ load_conversations
178
+ rows = @ungrouped.dup
179
+ stacks = {}
180
+
181
+ @stacked.each do |conversation|
182
+ counterpart = stacked_counterpart(conversation)
183
+ # The SQL prefilter matches by polymorphic type; an STI sibling that
184
+ # isn't actually grouped lands here and goes back to being a row.
185
+ next rows << conversation if counterpart.nil?
186
+
187
+ (stacks[Chats.messager_key(counterpart)] ||= [counterpart, []]).last << conversation
188
+ end
189
+
190
+ rows.concat(stacks.each_value.map { |messager, members| build_group(messager, members) })
191
+ sort_rows(rows).first(limit)
192
+ end
193
+
194
+ # Newest activity first, exactly like the SQL the relation would have
195
+ # used (COALESCE(last_message_at, created_at) DESC), at full timestamp
196
+ # precision — `to_r`, not `to_f`, because two messages a microsecond
197
+ # apart must not collapse into a tie. Ties break on id, so the order is
198
+ # total and a row never shuffles between renders.
199
+ def sort_rows(rows)
200
+ rows.sort do |a, b|
201
+ by_activity = sort_key(b).to_r <=> sort_key(a).to_r
202
+ by_activity.zero? ? compare_ids(b, a) : by_activity
203
+ end
204
+ end
205
+
206
+ def build_group(messager, members)
207
+ totals = stack_totals(messager)
208
+
209
+ Chats::InboxGroup.new(
210
+ messager: messager,
211
+ conversations: sort_rows(members),
212
+ unread_count: totals[:unread],
213
+ open_count: totals[:open]
214
+ )
215
+ end
216
+
217
+ # What a stacked row says about the WHOLE stack, in two indexed
218
+ # aggregates — independent of how deep the stack is, and of how much of
219
+ # it we loaded.
220
+ def stack_totals(messager)
221
+ scope = Chats::Conversation.inbox_for(viewer).reorder(nil).direct.where(
222
+ id: Chats::Participant.select(:conversation_id).where(
223
+ messager_type: messager.class.polymorphic_name, messager_id: messager.id
224
+ )
225
+ )
226
+ scope = Chats.config.inbox_scope.call(scope, viewer) || scope
227
+
228
+ {
229
+ open: scope.distinct.count,
230
+ unread: scope.unread_by(viewer).reorder(nil).count("chats_messages.id")
231
+ }
232
+ end
233
+
234
+ # The other party of a DIRECT conversation, when their class asked to be
235
+ # stacked (`acts_as_messager inbox: :grouped`). Read from the preloaded
236
+ # participants — no extra query per row.
237
+ def stacked_counterpart(conversation)
238
+ return nil unless conversation.direct?
239
+
240
+ other = conversation.participants.find do |participant|
241
+ participant.messager.present? && participant.messager != viewer
242
+ end&.messager
243
+
244
+ other if Chats.grouped_inbox?(other)
245
+ end
246
+
247
+ def sort_key(row)
248
+ row.last_message_at || (row.respond_to?(:created_at) ? row.created_at : nil) || Chats::Conversation::EPOCH
249
+ end
250
+
251
+ # The total-order tiebreak: a conversation's own id, a stack's freshest
252
+ # conversation's id. Works for bigint and uuid keys alike, and never
253
+ # raises on a pair it can't compare — it just calls them equal.
254
+ def compare_ids(a, b)
255
+ left = tiebreak(a)
256
+ right = tiebreak(b)
257
+ return 0 if left.nil? || right.nil? || left.class != right.class
258
+
259
+ left <=> right
260
+ end
261
+
262
+ def tiebreak(row)
263
+ row.is_a?(Chats::InboxGroup) ? row.conversation&.id : row.id
264
+ end
265
+
266
+ # Partial, case-insensitive matching across the inbox metadata users can
267
+ # actually see: participant names, conversation titles, subject labels,
268
+ # and message bodies. Each leg is capped (config.inbox_limit), so
269
+ # metadata is filtered portably in Ruby from the already-preloaded
270
+ # objects while the potentially larger message-body set stays in SQL. No
271
+ # PostgreSQL-only full-text dependency is needed at this scale.
272
+ def apply_search(relation)
273
+ return relation.to_a unless Chats.config.search && query
274
+
275
+ loaded = relation.to_a
276
+ normalized_query = query.downcase
277
+ message_match_ids = conversations_matching_body(loaded)
278
+
279
+ loaded.select do |conversation|
280
+ message_match_ids.include?(conversation.id) ||
281
+ searchable_metadata(conversation).downcase.include?(normalized_query)
282
+ end
283
+ end
284
+
285
+ def conversations_matching_body(conversations)
286
+ return [] if Chats.config.encrypt_messages || conversations.empty?
287
+
288
+ pattern = "%#{Chats::Conversation.sanitize_sql_like(query.downcase)}%"
289
+ Chats::Message.where(conversation_id: conversations.map(&:id), deleted_at: nil)
290
+ .where("LOWER(chats_messages.body) LIKE ?", pattern)
291
+ .distinct
292
+ .pluck(:conversation_id)
293
+ end
294
+
295
+ def searchable_metadata(conversation)
296
+ participant_names = conversation.participants.filter_map do |participant|
297
+ Chats.display_name_for(participant.messager) if participant.active?
298
+ end
299
+
300
+ [conversation.title, conversation.subject_label, *participant_names].compact.join(" ")
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # A stack of direct conversations that share one counterpart, shown as a
5
+ # SINGLE inbox row. Built by Chats::Inbox for messagers declared with
6
+ # `acts_as_messager inbox: :grouped` — a support desk, a marketplace
7
+ # storefront, any seat a person ends up with many separate threads with.
8
+ #
9
+ # It quacks like the parts of Chats::Conversation the inbox row needs
10
+ # (+last_message+, +last_message_at+, +unread_count+) so the two row
11
+ # partials stay symmetrical.
12
+ #
13
+ # +unread_count+ and +open_count+ are GLOBAL: they describe the whole
14
+ # stack, however deep it runs, not the bounded window in +conversations+.
15
+ # Chats::Inbox reads them with two indexed aggregates per stack.
16
+ class InboxGroup
17
+ # +conversations+ is the loaded WINDOW of the stack (freshest first,
18
+ # bounded by config.inbox_limit); +open_count+ and +unread_count+ describe
19
+ # the whole stack, however deep it runs.
20
+ attr_reader :messager, :conversations, :unread_count, :open_count
21
+
22
+ def initialize(messager:, conversations:, unread_count: 0, open_count: nil)
23
+ @messager = messager
24
+ @conversations = conversations
25
+ @unread_count = unread_count
26
+ @open_count = open_count || conversations.size
27
+ end
28
+
29
+ # A stack of one is really just a conversation: the row links straight to
30
+ # it, and the thread carries a "see all" link back to the stack.
31
+ def single?
32
+ open_count == 1
33
+ end
34
+
35
+ # The freshest conversation in the stack — what the row previews, and
36
+ # what it links to when the stack holds exactly one.
37
+ def conversation
38
+ conversations.first
39
+ end
40
+
41
+ # The stack's most recent message (the row's preview line).
42
+ def last_message
43
+ conversation&.last_message
44
+ end
45
+
46
+ # The sort key the inbox orders rows by: the freshest activity in the
47
+ # stack (mirrors Conversation.recent_first's COALESCE).
48
+ def last_message_at
49
+ conversations.filter_map { |c| c.last_message_at || c.created_at }.max
50
+ end
51
+
52
+ # Whether the stack has anything unread in it, anywhere.
53
+ def unread?
54
+ unread_count.positive?
55
+ end
56
+
57
+ # What the row is called. A stack is named after its counterpart from
58
+ # every seat, so the viewer is accepted and ignored — the signature
59
+ # matches Chats::Conversation#title_for so both row partials can call it.
60
+ def title_for(_viewer)
61
+ Chats.display_name_for(messager)
62
+ end
63
+
64
+ # Stable DOM id for the row (no AR record to derive one from).
65
+ def dom_id
66
+ "chats_inbox_group_#{messager.class.polymorphic_name.underscore.tr("/", "_")}_#{messager.id}"
67
+ end
68
+
69
+ # The signed, purpose-scoped GlobalID that filters the inbox to this
70
+ # stack (`GET /conversations?with=…`).
71
+ def with_sgid
72
+ Chats.inbox_with_sgid(messager)
73
+ end
74
+ end
75
+ end
data/lib/chats/macros.rb CHANGED
@@ -17,8 +17,40 @@ module Chats
17
17
  # lives in Chats::Messager / Chats::ChatSubject so it's discoverable,
18
18
  # testable, and `include`-able directly when a host prefers that style.
19
19
  module Macros
20
- def acts_as_messager
20
+ # Options (all optional; the defaults are 0.1.x behaviour):
21
+ #
22
+ # notifications: false this messager is never notifiable — a support
23
+ # desk, a bot, an org mailbox. `Participant#
24
+ # notifiable_for?` says no, so hosts stop
25
+ # branching on class in every notifier.
26
+ # blockable: false block/report affordances don't apply to it, so
27
+ # the bundled views hide them.
28
+ # inbox: :grouped every direct conversation with this messager
29
+ # stacks into ONE inbox row (see Chats::Inbox).
30
+ # group_path: ->(viewer) { } where that stacked row links to; defaults
31
+ # to the filtered inbox (`?with=<sgid>`).
32
+ # verified: true this messager is an OFFICIAL account — a support
33
+ # desk, an organization, a brand. The bundled views
34
+ # mark its name with the rosette everyone already
35
+ # reads as "verified". Strictly true/false: a badge
36
+ # is a trust claim, so a stray "false" string must
37
+ # raise rather than quietly verify.
38
+ #
39
+ # class Desk < ApplicationRecord
40
+ # acts_as_messager notifications: false, blockable: false, inbox: :grouped,
41
+ # verified: true
42
+ # end
43
+ def acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil,
44
+ verified: false)
21
45
  include Chats::Messager
46
+
47
+ self.chat_options = Chats::Messager.normalize_options(
48
+ notifications: notifications,
49
+ blockable: blockable,
50
+ inbox: inbox,
51
+ group_path: group_path,
52
+ verified: verified
53
+ )
22
54
  end
23
55
 
24
56
  def acts_as_chat_subject
@@ -31,5 +31,28 @@ module Chats
31
31
  def chat_subject_label
32
32
  "#{self.class.model_name.human} #{id}"
33
33
  end
34
+
35
+ # Whether conversations ABOUT this record still accept messages. The
36
+ # subject already owns the conversation's meaning, so it owns its
37
+ # openness too — a closed ticket, a delivered order, an archived listing:
38
+ #
39
+ # class Ticket < ApplicationRecord
40
+ # acts_as_chat_subject
41
+ # def chat_locked? = closed?
42
+ # def chat_locked_notice = "This ticket is closed. Reply to reopen it."
43
+ # end
44
+ #
45
+ # System messages are exempt (the host can always post "Ticket closed"),
46
+ # and locking is a WRITE rule only: the thread stays readable. See
47
+ # Chats::Conversation#locked?.
48
+ def chat_locked?
49
+ false
50
+ end
51
+
52
+ # The sentence shown where the composer would be. Nil falls back to the
53
+ # gem's localized "This conversation is closed."
54
+ def chat_locked_notice
55
+ nil
56
+ end
34
57
  end
35
58
  end
@@ -16,7 +16,93 @@ module Chats
16
16
  module Messager
17
17
  extend ActiveSupport::Concern
18
18
 
19
+ # What `acts_as_messager` declares about a messager class. The defaults
20
+ # are exactly 0.1.x behaviour, so a bare `acts_as_messager` is unchanged.
21
+ DEFAULT_CHAT_OPTIONS = {
22
+ notifications: true,
23
+ blockable: true,
24
+ inbox: :default,
25
+ group_path: nil,
26
+ verified: false
27
+ }.freeze
28
+
29
+ INBOX_MODES = %i[default grouped].freeze
30
+
31
+ # Validate + freeze the macro's options, failing at BOOT with a plain
32
+ # English message rather than at 3am with a NoMethodError.
33
+ def self.normalize_options(notifications:, blockable:, inbox:, group_path:, verified: false)
34
+ inbox = inbox.to_sym
35
+ unless INBOX_MODES.include?(inbox)
36
+ raise Chats::ConfigurationError,
37
+ "acts_as_messager inbox: must be one of #{INBOX_MODES.inspect}, got #{inbox.inspect}"
38
+ end
39
+
40
+ if group_path && !group_path.respond_to?(:call)
41
+ raise Chats::ConfigurationError,
42
+ "acts_as_messager group_path: must respond to #call (a proc/lambda), got #{group_path.inspect}"
43
+ end
44
+
45
+ # Deliberately STRICTER than its boolean neighbours, which coerce with
46
+ # `!!`. "Official account" is a trust claim shown to everyone who talks
47
+ # to this messager, so `verified: "false"` (an ENV var, a YAML
48
+ # round-trip) has to fail at boot rather than quietly verify it.
49
+ unless [true, false].include?(verified)
50
+ raise Chats::ConfigurationError,
51
+ "acts_as_messager verified: must be true or false, got #{verified.inspect}"
52
+ end
53
+
54
+ {
55
+ notifications: !!notifications,
56
+ blockable: !!blockable,
57
+ inbox: inbox,
58
+ group_path: group_path,
59
+ verified: verified
60
+ }.freeze
61
+ end
62
+
63
+ class_methods do
64
+ # True unless declared with `acts_as_messager notifications: false`.
65
+ def chat_notifications?
66
+ chat_options[:notifications]
67
+ end
68
+
69
+ # True unless declared with `acts_as_messager blockable: false`.
70
+ def chat_blockable?
71
+ chat_options[:blockable]
72
+ end
73
+
74
+ # :default | :grouped (see Chats::Inbox).
75
+ def chat_inbox_mode
76
+ chat_options[:inbox]
77
+ end
78
+
79
+ # Whether every direct conversation with this messager folds into ONE
80
+ # inbox row (see Chats::InboxGroup).
81
+ def chat_grouped_inbox?
82
+ chat_inbox_mode == :grouped
83
+ end
84
+
85
+ # The `group_path:` callable, or nil (then the stacked row links to the
86
+ # filtered inbox).
87
+ def chat_group_path
88
+ chat_options[:group_path]
89
+ end
90
+
91
+ # True when declared with `acts_as_messager verified: true` — an
92
+ # OFFICIAL account (a support desk, an organization, a brand). The
93
+ # bundled views badge its name wherever they show it.
94
+ def chat_verified?
95
+ chat_options[:verified]
96
+ end
97
+ end
98
+
19
99
  included do
100
+ # Declared by `acts_as_messager`; a plain `include Chats::Messager`
101
+ # gets the defaults. class_attribute so STI subclasses inherit it.
102
+ class_attribute :chat_options,
103
+ instance_accessor: false,
104
+ default: Chats::Messager::DEFAULT_CHAT_OPTIONS
105
+
20
106
  has_many :chat_participations,
21
107
  class_name: "Chats::Participant",
22
108
  as: :messager,
@@ -63,14 +149,25 @@ module Chats
63
149
  # alice.message!(bob, "are you coming?")
64
150
  # alice.message!(bob, "about the ride", about: ride)
65
151
  # alice.message!(conversation, "hi all!", files: [photo])
66
- def message!(target, body = nil, about: nil, files: [], reply_to: nil)
152
+ #
153
+ # `author:` is the human (or bot) writing on the SENDER's behalf — an
154
+ # agent answering from a support desk seat. It signs the bubble; the
155
+ # sender stays the conversation identity. See Chats::Message#signed?.
156
+ #
157
+ # desk.message!(alice, "On it!", author: lucia)
158
+ def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil)
159
+ if author && !Chats.messager_class?(author.class)
160
+ raise Chats::NotAllowedError,
161
+ "author must be a messager (acts_as_messager), got #{author.class.name}"
162
+ end
163
+
67
164
  conversation =
68
165
  case target
69
166
  when Chats::Conversation then target
70
167
  else chat_with(target, about: about)
71
168
  end
72
169
 
73
- attributes = { sender: self, body: body, reply_to: reply_to }
170
+ attributes = { sender: self, body: body, reply_to: reply_to, author: author }
74
171
  attributes[:files] = files if files.present?
75
172
  conversation.messages.create!(**attributes)
76
173
  end