chats 0.1.1 → 0.6.2

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1037 -0
  3. data/README.md +621 -40
  4. data/app/assets/stylesheets/chats.css +906 -2
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +396 -86
  7. data/app/controllers/chats/messages_controller.rb +35 -13
  8. data/app/controllers/chats/reactions_controller.rb +14 -0
  9. data/app/helpers/chats/engine_helper.rb +230 -7
  10. data/app/javascript/chats/composer_controller.js +14 -1
  11. data/app/javascript/chats/dock_controller.js +1120 -0
  12. data/app/javascript/chats/recovery.js +539 -0
  13. data/app/javascript/chats/refresh_inbox_controller.js +76 -0
  14. data/app/javascript/chats/split_controller.js +131 -0
  15. data/app/javascript/chats/thread_controller.js +322 -60
  16. data/app/javascript/chats/unread_badge_controller.js +24 -0
  17. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  18. data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
  19. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  20. data/app/views/chats/conversations/_group.html.erb +61 -0
  21. data/app/views/chats/conversations/_history.html.erb +54 -0
  22. data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
  23. data/app/views/chats/conversations/_thread.html.erb +147 -0
  24. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  25. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  26. data/app/views/chats/conversations/dock.html.erb +40 -0
  27. data/app/views/chats/conversations/index.html.erb +142 -15
  28. data/app/views/chats/conversations/pane.html.erb +14 -0
  29. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  30. data/app/views/chats/conversations/show.html.erb +18 -134
  31. data/app/views/chats/conversations/window.html.erb +26 -0
  32. data/app/views/chats/dock/_badge.html.erb +12 -0
  33. data/app/views/chats/dock/_dock.html.erb +139 -0
  34. data/app/views/chats/dock/_signal.html.erb +26 -0
  35. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  36. data/app/views/chats/dock/_window.html.erb +23 -0
  37. data/app/views/chats/dock/_window_header.html.erb +75 -0
  38. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  39. data/app/views/chats/messages/_action.html.erb +15 -0
  40. data/app/views/chats/messages/_composer.html.erb +84 -56
  41. data/app/views/chats/messages/_message.html.erb +43 -15
  42. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  43. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  44. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  45. data/config/importmap.rb +10 -1
  46. data/config/locales/en.yml +40 -0
  47. data/config/locales/es.yml +40 -0
  48. data/config/routes.rb +8 -0
  49. data/context7.json +4 -0
  50. data/docs/PRD.md +1 -1
  51. data/docs/campfire_review.md +1 -1
  52. data/docs/screenshots/composer-actions.png +0 -0
  53. data/docs/screenshots/dock-bar.png +0 -0
  54. data/docs/screenshots/dock-desktop.png +0 -0
  55. data/docs/screenshots/dock-phone-button.png +0 -0
  56. data/docs/screenshots/dock-phone-panel.png +0 -0
  57. data/docs/screenshots/dock-phone-window.png +0 -0
  58. data/docs/screenshots/inbox.png +0 -0
  59. data/docs/screenshots/split-inbox-empty.png +0 -0
  60. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  61. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  62. data/docs/screenshots/split-inbox.png +0 -0
  63. data/docs/screenshots/system-note-action.png +0 -0
  64. data/docs/screenshots/thread-actions-phone.png +0 -0
  65. data/gemfiles/rails_7.1.gemfile +3 -0
  66. data/gemfiles/rails_7.2.gemfile +3 -0
  67. data/gemfiles/rails_8.1.gemfile +3 -0
  68. data/lib/chats/action.rb +85 -0
  69. data/lib/chats/broadcasts.rb +178 -17
  70. data/lib/chats/configuration.rb +115 -1
  71. data/lib/chats/engine.rb +29 -7
  72. data/lib/chats/errors.rb +16 -0
  73. data/lib/chats/inbox.rb +363 -0
  74. data/lib/chats/inbox_group.rb +75 -0
  75. data/lib/chats/inbox_refresh_job.rb +14 -0
  76. data/lib/chats/macros.rb +33 -1
  77. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  78. data/lib/chats/models/concerns/messager.rb +122 -6
  79. data/lib/chats/models/conversation.rb +153 -19
  80. data/lib/chats/models/message.rb +292 -21
  81. data/lib/chats/models/participant.rb +67 -0
  82. data/lib/chats/models/reaction.rb +18 -0
  83. data/lib/chats/send_rate_limited.rb +30 -0
  84. data/lib/chats/subscribers.rb +156 -0
  85. data/lib/chats/version.rb +1 -1
  86. data/lib/chats.rb +123 -15
  87. data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
  88. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  89. data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
  90. data/lib/generators/chats/templates/initializer.rb +125 -14
  91. data/lib/generators/chats/upgrade_generator.rb +79 -0
  92. data/lib/generators/chats/views_generator.rb +3 -2
  93. metadata +51 -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 send_rate_limited.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,363 @@
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
+ # Chats::Inbox.for(alice, limit: 10) # the messaging dock's recent list
12
+ #
13
+ # Rows are `Chats::Conversation | Chats::InboxGroup`, sorted by last
14
+ # activity descending. Grouping happens HERE and nowhere else, and so does
15
+ # the rule that a conversation with no messages is never listed.
16
+ #
17
+ # == Why `inbox_limit` bounds ROWS, not conversations
18
+ #
19
+ # A stacked counterpart can hold hundreds of threads. Limiting the raw
20
+ # conversation query first would let a busy support desk EVICT everything
21
+ # else from the inbox — 200 desk threads and not one message from a friend.
22
+ # So the two populations are queried separately: ordinary conversations get
23
+ # the limit, stacked ones get their own bounded window, and the limit is
24
+ # applied again to the ROWS that come out. A stack's numbers
25
+ # (`open_count`, `unread_count`) are then GLOBAL, read with two indexed
26
+ # aggregates per stack — never per conversation, and never by loading the
27
+ # stack to count it.
28
+ class Inbox
29
+ include Enumerable
30
+
31
+ attr_reader :viewer, :query, :with, :limit
32
+
33
+ class << self
34
+ # The inbox for +viewer+. Returns a Chats::Inbox, which enumerates its
35
+ # rows (`to_a` for a plain Array).
36
+ def for(viewer, query: nil, with: nil, limit: nil)
37
+ new(viewer, query: query, with: with, limit: limit)
38
+ end
39
+ end
40
+
41
+ # +limit+ is how many ROWS come back (`config.inbox_limit` by default).
42
+ # A smaller one is a smaller query, not a slice of the big one — except
43
+ # while searching, which still looks through the whole `inbox_limit`
44
+ # window (a match is no less a match for being the eleventh most
45
+ # recent conversation) and then returns at most +limit+ hits.
46
+ def initialize(viewer, query: nil, with: nil, limit: nil)
47
+ @viewer = viewer
48
+ @query = query.to_s.strip.presence
49
+ @with = with
50
+ @limit = Integer(limit || Chats.config.inbox_limit).clamp(1, Chats.config.inbox_limit)
51
+ end
52
+
53
+ # Conversation | InboxGroup rows, newest activity first, at most
54
+ # +limit+ of them.
55
+ def rows
56
+ @rows ||= build_rows
57
+ end
58
+
59
+ # Yield each row (Enumerable gives `map`, `select`, `find`, … from here).
60
+ def each(&)
61
+ rows.each(&)
62
+ end
63
+
64
+ # The rows as a plain Array.
65
+ def to_a
66
+ rows
67
+ end
68
+
69
+ # How many rows the inbox has.
70
+ def size
71
+ rows.size
72
+ end
73
+
74
+ # Whether the inbox has any rows at all.
75
+ def any?
76
+ rows.any?
77
+ end
78
+
79
+ # Whether the inbox has no rows.
80
+ def empty?
81
+ rows.empty?
82
+ end
83
+
84
+ # The conversations behind the rows, stacked threads included. Stays an
85
+ # ActiveRecord::Relation (already loaded) in the simple case — no search,
86
+ # nothing stacked — so an inbox ejected under 0.1.x can still chain
87
+ # `.where` or hand it to a paginator. Becomes an Array once rows had to
88
+ # be assembled in Ruby.
89
+ def conversations
90
+ rows
91
+ @flat
92
+ end
93
+
94
+ # { conversation_id => unread message count } — one grouped query for
95
+ # every loaded conversation, which is what the row badges read.
96
+ def unread_counts
97
+ @unread_counts ||= Chats::Conversation.unread_counts_for(viewer, conversations)
98
+ end
99
+
100
+ # Unread messages in one conversation, from the grouped query above.
101
+ def unread_count_for(conversation)
102
+ unread_counts.fetch(conversation.id, 0)
103
+ end
104
+
105
+ # The stack-aware badge number: how many inbox ROWS carry unread content
106
+ # (a stack of five unread threads is still one thing to deal with).
107
+ # `Messager#unread_chats_count` is the unstacked count and is unchanged.
108
+ def unread_count
109
+ rows.count do |row|
110
+ row.is_a?(Chats::InboxGroup) ? row.unread? : unread_count_for(row).positive?
111
+ end
112
+ end
113
+
114
+ # True when scoped to one counterpart (`?with=`): a stack's contents,
115
+ # which are listed individually rather than re-stacked.
116
+ def filtered?
117
+ with.present?
118
+ end
119
+
120
+ private
121
+
122
+ # How many conversations each leg of the query loads: the row limit, or
123
+ # the whole inbox window while a search filters it in Ruby.
124
+ def window
125
+ query ? Chats.config.inbox_limit : limit
126
+ end
127
+
128
+ # The viewer's own inbox relation (`Messager#chats`: `inbox_for` with
129
+ # `config.inbox_scope` applied, and whatever the host's model adds on
130
+ # top), memoized so it is built ONCE per inbox however many legs the
131
+ # row query is split into (the stack aggregates build their own).
132
+ #
133
+ # Only conversations with at least one message (Conversation.
134
+ # with_messages, system messages and tombstones included): one that was
135
+ # opened and never used is not listed — not as a row, not in a
136
+ # stack, not in search, not in the `?with=` view. It still OPENS, because
137
+ # authorization reads `viewer.chats`, not this.
138
+ def base_relation
139
+ @base_relation ||= listed(viewer.chats).includes(:last_message, :subject, participants: :messager)
140
+ end
141
+
142
+ # What a list may show of +relation+. The one place the rule lives, so
143
+ # the rows and the stack totals can never disagree about it.
144
+ def listed(relation)
145
+ relation.with_messages
146
+ end
147
+
148
+ # The polymorphic types worth splitting out. Empty when nothing stacks
149
+ # (an ordinary app pays nothing) and when the inbox is already filtered
150
+ # to one counterpart.
151
+ def grouped_types
152
+ @grouped_types ||= filtered? ? [] : Chats.grouped_messager_types
153
+ end
154
+
155
+ # Seats held by a stacked messager — never the viewer's own seat, so a
156
+ # stacked messager's OWN inbox stays flat.
157
+ def stacked_seats
158
+ Chats::Participant.select(:conversation_id)
159
+ .where(messager_type: grouped_types)
160
+ .where.not(messager_type: viewer.class.polymorphic_name, messager_id: viewer.id)
161
+ end
162
+
163
+ def load_conversations
164
+ if filtered?
165
+ @ungrouped_relation = filter_to_counterpart(base_relation).limit(window)
166
+ @stacked = []
167
+ elsif grouped_types.empty?
168
+ @ungrouped_relation = base_relation.limit(window)
169
+ @stacked = []
170
+ else
171
+ stacked = Chats::Conversation.direct.where(id: stacked_seats)
172
+ @ungrouped_relation = base_relation.where.not(id: stacked).limit(window)
173
+ recent = base_relation.direct.where(id: stacked_seats).except(:includes).select(:id).limit(window)
174
+ # Preserve the recent search window, plus the freshest conversation
175
+ # of each counterpart. A busy stack cannot consume another stack's
176
+ # row; at most twice the row limit is materialized.
177
+ candidates = base_relation.where(id: limited_ids(recent))
178
+ .or(base_relation.where(id: limited_ids(stack_representatives)))
179
+ @stacked = apply_search(candidates)
180
+ end
181
+
182
+ @ungrouped = apply_search(@ungrouped_relation)
183
+ # The relation itself when nothing had to be assembled in Ruby (it is
184
+ # loaded, so iterating it costs nothing extra); the flat Array otherwise.
185
+ @flat = @stacked.empty? && query.nil? ? @ungrouped_relation : @ungrouped + @stacked
186
+ end
187
+
188
+ # MySQL rejects LIMIT directly in an IN subquery. A derived table keeps
189
+ # the bounded selection in SQL and works on all three supported adapters.
190
+ def limited_ids(relation)
191
+ Chats::Conversation.from(relation, :limited_conversations).select(:id)
192
+ end
193
+
194
+ # ROW_NUMBER works on every supported adapter (PostgreSQL, SQLite and
195
+ # MySQL 8). Ranking in SQL avoids loading an entire busy desk's history.
196
+ def stack_representatives
197
+ activity = "COALESCE(chats_conversations.last_message_at, chats_conversations.created_at)"
198
+ ranking = "ROW_NUMBER() OVER (PARTITION BY stack_seats.messager_type, stack_seats.messager_id " \
199
+ "ORDER BY #{activity} DESC, chats_conversations.id DESC) AS stack_rank"
200
+ ranked = base_relation.direct.except(:includes, :order)
201
+ .joins("INNER JOIN chats_participants stack_seats " \
202
+ "ON stack_seats.conversation_id = chats_conversations.id")
203
+ .where(stack_seats: { messager_type: grouped_types })
204
+ .where.not(stack_seats: { messager_type: viewer.class.polymorphic_name,
205
+ messager_id: viewer.id })
206
+ .select("chats_conversations.id, #{activity} AS activity_at", ranking)
207
+ Chats::Conversation.from(ranked, :ranked_stacks).where("stack_rank = 1")
208
+ .order(Arel.sql("activity_at DESC, id DESC")).limit(window).select(:id)
209
+ end
210
+
211
+ # Only the direct threads shared with one counterpart. Direct only, by
212
+ # design: a group that happens to include the desk is not part of the
213
+ # desk's stack.
214
+ def filter_to_counterpart(relation)
215
+ seats = Chats::Participant.select(:conversation_id).where(
216
+ messager_type: with.class.polymorphic_name, messager_id: with.id
217
+ )
218
+ relation.direct.where(id: seats)
219
+ end
220
+
221
+ def build_rows
222
+ load_conversations
223
+ rows = @ungrouped.dup
224
+ stacks = {}
225
+
226
+ @stacked.each do |conversation|
227
+ counterpart = stacked_counterpart(conversation)
228
+ # The SQL prefilter matches by polymorphic type; an STI sibling that
229
+ # isn't actually grouped lands here and goes back to being a row.
230
+ next rows << conversation if counterpart.nil?
231
+
232
+ (stacks[Chats.messager_key(counterpart)] ||= [counterpart, []]).last << conversation
233
+ end
234
+
235
+ totals = stack_totals(stacks.each_value.map(&:first))
236
+ rows.concat(stacks.each_value.map { |messager, members| build_group(messager, members, totals) })
237
+ sort_rows(rows).first(limit)
238
+ end
239
+
240
+ # Newest activity first, exactly like the SQL the relation would have
241
+ # used (COALESCE(last_message_at, created_at) DESC), at full timestamp
242
+ # precision — `to_r`, not `to_f`, because two messages a microsecond
243
+ # apart must not collapse into a tie. Ties break on id, so the order is
244
+ # total and a row never shuffles between renders.
245
+ def sort_rows(rows)
246
+ rows.sort do |a, b|
247
+ by_activity = sort_key(b).to_r <=> sort_key(a).to_r
248
+ by_activity.zero? ? compare_ids(b, a) : by_activity
249
+ end
250
+ end
251
+
252
+ def build_group(messager, members, totals)
253
+ key = stack_key(messager.class.polymorphic_name, messager.id)
254
+
255
+ Chats::InboxGroup.new(
256
+ messager: messager,
257
+ conversations: sort_rows(members),
258
+ unread_count: totals[:unread].fetch(key, 0),
259
+ open_count: totals[:open].fetch(key, 0)
260
+ )
261
+ end
262
+
263
+ # What each stacked row says about its WHOLE stack, for every stack at
264
+ # once: two grouped aggregates, however many stacks the inbox shows and
265
+ # however deep each one runs — never per stack, never by loading one.
266
+ # Grouped by the counterpart's seat; the type and id lists may pair up
267
+ # a few seats that aren't stacks here, and those rows are simply never
268
+ # read back.
269
+ def stack_totals(messagers)
270
+ return { open: {}, unread: {} } if messagers.empty?
271
+
272
+ types = messagers.map { |messager| messager.class.polymorphic_name }.uniq
273
+ seats = listed(viewer.chats).reorder(nil).direct
274
+ .joins("INNER JOIN chats_participants stack_seats " \
275
+ "ON stack_seats.conversation_id = chats_conversations.id")
276
+ .where(stack_seats: { messager_type: types, messager_id: messagers.map(&:id) })
277
+ .group("stack_seats.messager_type", "stack_seats.messager_id")
278
+
279
+ {
280
+ open: keyed(seats.distinct.count("chats_conversations.id")),
281
+ unread: keyed(seats.unread_by(viewer).count("chats_messages.id"))
282
+ }
283
+ end
284
+
285
+ def keyed(counts)
286
+ counts.transform_keys { |(type, id)| stack_key(type, id) }
287
+ end
288
+
289
+ # uuid and bigint keys alike: the database may hand ids back as strings.
290
+ def stack_key(type, id)
291
+ [type, id.to_s]
292
+ end
293
+
294
+ # The other party of a DIRECT conversation, when their class asked to be
295
+ # stacked (`acts_as_messager inbox: :grouped`). Read from the preloaded
296
+ # participants — no extra query per row.
297
+ def stacked_counterpart(conversation)
298
+ return nil unless conversation.direct?
299
+
300
+ other = conversation.participants.find do |participant|
301
+ participant.messager.present? && participant.messager != viewer
302
+ end&.messager
303
+
304
+ other if Chats.grouped_inbox?(other)
305
+ end
306
+
307
+ def sort_key(row)
308
+ row.last_message_at || (row.respond_to?(:created_at) ? row.created_at : nil) || Chats::Conversation::EPOCH
309
+ end
310
+
311
+ # The total-order tiebreak: a conversation's own id, a stack's freshest
312
+ # conversation's id. Works for bigint and uuid keys alike, and never
313
+ # raises on a pair it can't compare — it just calls them equal.
314
+ def compare_ids(a, b)
315
+ left = tiebreak(a)
316
+ right = tiebreak(b)
317
+ return 0 if left.nil? || right.nil? || left.class != right.class
318
+
319
+ left <=> right
320
+ end
321
+
322
+ def tiebreak(row)
323
+ row.is_a?(Chats::InboxGroup) ? row.conversation&.id : row.id
324
+ end
325
+
326
+ # Partial, case-insensitive matching across the inbox metadata users can
327
+ # actually see: participant names, conversation titles, subject labels,
328
+ # and message bodies. Each leg is capped (config.inbox_limit), so
329
+ # metadata is filtered portably in Ruby from the already-preloaded
330
+ # objects while the potentially larger message-body set stays in SQL. No
331
+ # PostgreSQL-only full-text dependency is needed at this scale.
332
+ def apply_search(relation)
333
+ return relation.to_a unless Chats.config.search && query
334
+
335
+ loaded = relation.to_a
336
+ normalized_query = query.downcase
337
+ message_match_ids = conversations_matching_body(loaded)
338
+
339
+ loaded.select do |conversation|
340
+ message_match_ids.include?(conversation.id) ||
341
+ searchable_metadata(conversation).downcase.include?(normalized_query)
342
+ end
343
+ end
344
+
345
+ def conversations_matching_body(conversations)
346
+ return [] if Chats.config.encrypt_messages || conversations.empty?
347
+
348
+ pattern = "%#{Chats::Conversation.sanitize_sql_like(query.downcase)}%"
349
+ Chats::Message.where(conversation_id: conversations.map(&:id), deleted_at: nil)
350
+ .where("LOWER(chats_messages.body) LIKE ?", pattern)
351
+ .distinct
352
+ .pluck(:conversation_id)
353
+ end
354
+
355
+ def searchable_metadata(conversation)
356
+ participant_names = conversation.participants.filter_map do |participant|
357
+ Chats.display_name_for(participant.messager) if participant.active?
358
+ end
359
+
360
+ [conversation.title, conversation.subject_label, *participant_names].compact.join(" ")
361
+ end
362
+ end
363
+ 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
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # One messager's inbox refresh (Chats::Broadcasts.refresh_inbox_of): at
5
+ # most one queued per messager, standing for every write that commits
6
+ # before it starts. A messager deleted before it ran has no inbox.
7
+ class InboxRefreshJob < ActiveJob::Base
8
+ discard_on ActiveJob::DeserializationError
9
+
10
+ def perform(messager)
11
+ Chats::Broadcasts.refresh_inbox_now(messager)
12
+ end
13
+ end
14
+ 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