chats 0.4.2 → 0.5.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +478 -0
  3. data/README.md +150 -10
  4. data/app/assets/stylesheets/chats.css +599 -0
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +273 -55
  7. data/app/helpers/chats/engine_helper.rb +102 -6
  8. data/app/javascript/chats/composer_controller.js +14 -1
  9. data/app/javascript/chats/dock_controller.js +1106 -0
  10. data/app/javascript/chats/recovery.js +539 -0
  11. data/app/javascript/chats/refresh_inbox_controller.js +55 -65
  12. data/app/javascript/chats/thread_controller.js +317 -60
  13. data/app/javascript/chats/unread_badge_controller.js +24 -0
  14. data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
  15. data/app/views/chats/conversations/_group.html.erb +6 -2
  16. data/app/views/chats/conversations/_history.html.erb +54 -0
  17. data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
  18. data/app/views/chats/conversations/_thread.html.erb +133 -0
  19. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  20. data/app/views/chats/conversations/dock.html.erb +40 -0
  21. data/app/views/chats/conversations/index.html.erb +24 -10
  22. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  23. data/app/views/chats/conversations/show.html.erb +11 -118
  24. data/app/views/chats/conversations/window.html.erb +26 -0
  25. data/app/views/chats/dock/_badge.html.erb +12 -0
  26. data/app/views/chats/dock/_dock.html.erb +138 -0
  27. data/app/views/chats/dock/_signal.html.erb +26 -0
  28. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  29. data/app/views/chats/dock/_window.html.erb +23 -0
  30. data/app/views/chats/dock/_window_header.html.erb +75 -0
  31. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  32. data/app/views/chats/messages/_composer.html.erb +11 -1
  33. data/app/views/chats/messages/_message.html.erb +8 -6
  34. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  35. data/config/importmap.rb +7 -0
  36. data/config/locales/en.yml +17 -0
  37. data/config/locales/es.yml +17 -0
  38. data/config/routes.rb +8 -0
  39. data/docs/screenshots/dock-bar.png +0 -0
  40. data/docs/screenshots/dock-desktop.png +0 -0
  41. data/docs/screenshots/dock-phone-button.png +0 -0
  42. data/docs/screenshots/dock-phone-panel.png +0 -0
  43. data/docs/screenshots/dock-phone-window.png +0 -0
  44. data/gemfiles/rails_7.1.gemfile +2 -0
  45. data/gemfiles/rails_7.2.gemfile +2 -0
  46. data/gemfiles/rails_8.1.gemfile +2 -0
  47. data/lib/chats/broadcasts.rb +178 -17
  48. data/lib/chats/inbox.rb +68 -30
  49. data/lib/chats/inbox_refresh_job.rb +14 -0
  50. data/lib/chats/models/concerns/messager.rb +10 -2
  51. data/lib/chats/models/conversation.rb +57 -0
  52. data/lib/chats/models/message.rb +98 -9
  53. data/lib/chats/models/reaction.rb +13 -0
  54. data/lib/chats/version.rb +1 -1
  55. data/lib/generators/chats/templates/initializer.rb +1 -0
  56. data/lib/generators/chats/views_generator.rb +3 -2
  57. metadata +23 -2
@@ -4,7 +4,7 @@ require "action_view/record_identifier"
4
4
 
5
5
  module Chats
6
6
  # ALL of the gem's real-time fan-out, in one file — read this and you know
7
- # the entire live behavior. Three streams exist:
7
+ # the entire live behavior. Four streams exist:
8
8
  #
9
9
  # 1. The CONVERSATION stream (`turbo_stream_from @conversation` on the
10
10
  # thread page): surgical message append/replace/remove, the read-state
@@ -20,9 +20,8 @@ module Chats
20
20
  # broadcast would force per-viewer renders of per-viewer partials;
21
21
  # a refresh makes each client re-request the page and get a correct,
22
22
  # personalized render with morphing + scroll preservation for free.
23
- # Turbo debounces refreshes per stream and tags them with the
24
- # originating request id so the tab that caused the change skips its
25
- # own refresh. https://turbo.hotwired.dev/handbook/streams
23
+ # At most one is queued per messager at a time (see
24
+ # refresh_inbox_of). https://turbo.hotwired.dev/handbook/streams
26
25
  #
27
26
  # 3. The per-messager BADGE stream (`[messager, :chats_badge]`,
28
27
  # subscribable from ANY page via the `chats_unread_badge` helper —
@@ -30,15 +29,46 @@ module Chats
30
29
  # Deliberately separate from the inbox stream — if badges rode on it,
31
30
  # every page embedding a badge would full-refresh on every message.
32
31
  #
32
+ # 4. The per-messager DOCK stream (`[messager, :chats_dock]`, subscribed
33
+ # by the messaging dock — `chats_dock`). One render per participant
34
+ # per event (chats/dock/_update), carrying up to two things:
35
+ # * the dock's BADGES, with the same count as stream 3, whenever the
36
+ # badge moves (a message for them, a read of theirs);
37
+ # * the SIGNAL (chats/dock/_signal), replaced so the dock's
38
+ # controller sees it connect: reload the recent list, and — for a
39
+ # new message the participant should hear about — which
40
+ # conversation it was, from whom, how much is unread there, and
41
+ # that its window may pop open. Senders and edits/deletes get the
42
+ # signal too (their lists reorder), never the pop-up.
43
+ # The hint names a conversation only to a participant who can SEE it
44
+ # (`messager.chats`: blocks and `inbox_scope` applied) — the dock's
45
+ # own list and window endpoints refuse anything else, and the stream
46
+ # must not tell them what those hide. No polling, no Action Cable
47
+ # channel of our own.
48
+ #
33
49
  # Everything uses the `_later` variants (rendering happens in background
34
50
  # jobs, never inline in the request) except `message_destroyed`, where the
35
- # record is gone and can't be serialized for a job.
51
+ # record is gone and can't be serialized for a job. Every job is enqueued
52
+ # from an after_commit callback, so a broadcast never announces a write
53
+ # that could still roll back, and never waits in memory: once the write
54
+ # has committed, its broadcast is in the job queue.
55
+ #
56
+ # A broadcast can still be missed — Action Cable has no replay, and a
57
+ # client whose socket is down when it is sent never gets it. The client
58
+ # side catches up (chats/recovery.js: on reconnect, on return to a hidden
59
+ # page, on network-online, on a back/forward-cache restore), so every
60
+ # surface fed by these streams heals without polling.
36
61
  module Broadcasts
37
62
  extend ActionView::RecordIdentifier # dom_id
38
63
 
39
64
  MESSAGE_PARTIAL = "chats/messages/message"
40
65
  BADGE_PARTIAL = "chats/shared/unread_badge"
66
+ DOCK_UPDATE_PARTIAL = "chats/dock/update"
41
67
  READ_STATE_PARTIAL = "chats/conversations/read_state"
68
+ # The inbox stream's whole payload. Built once, with no request id.
69
+ INBOX_REFRESH = %(<turbo-stream action="refresh" method="morph" scroll="preserve"></turbo-stream>)
70
+ # How long a queued inbox refresh stands for the writes after it.
71
+ INBOX_REFRESH_PENDING = 2.seconds
42
72
 
43
73
  class << self
44
74
  def message_created(message)
@@ -51,7 +81,7 @@ module Chats
51
81
  locals: { message: message }
52
82
  )
53
83
 
54
- fan_out_inboxes(conversation, skip_badge_for: message.sender)
84
+ fan_out_inboxes(conversation, skip_badge_for: message.sender, message: message)
55
85
  end
56
86
 
57
87
  def message_updated(message)
@@ -107,11 +137,73 @@ module Chats
107
137
  )
108
138
  end
109
139
 
140
+ # Straight into the job queue, NOT `broadcast_refresh_later_to`: that
141
+ # one first parks the refresh for half a second in an in-process
142
+ # timer (Turbo::Debouncer, a Concurrent::ScheduledTask) and only then
143
+ # enqueues the job, so a deploy or a crash inside that half second
144
+ # drops the refresh with nothing left to retry it.
145
+ #
146
+ # One job per messager at a time. A refresh carries no data — the
147
+ # client re-requests its inbox — so while one is queued and hasn't
148
+ # started, it covers every write that commits before it runs: a burst
149
+ # (five messages in a 100-person group) is one job per participant,
150
+ # not five. A marker in Rails.cache says one is queued; the job removes
151
+ # it as it STARTS, before it broadcasts, so a write that still finds
152
+ # the marker has committed before that broadcast, and a write after
153
+ # it queues the next job. Every way the marker can go wrong costs an
154
+ # extra job, never a missed refresh: a cache without it (null store,
155
+ # a per-process memory store, an eviction) just doesn't coalesce, and
156
+ # the marker expires after INBOX_REFRESH_PENDING, so a job lost from
157
+ # the queue holds nothing back for longer than that.
158
+ # Refreshes that still arrive close together merge on the client:
159
+ # Turbo runs one page refresh for refreshes within 150 ms of each
160
+ # other (Session#pageRefreshDebouncePeriod), and chats--refresh-inbox
161
+ # reruns one that landed mid-visit, which Turbo would ignore.
162
+ #
163
+ # The marker goes up BEFORE the enqueue, so another write can find it
164
+ # while the enqueue is still in flight and count on that refresh. An
165
+ # enqueue that fails is therefore tried once more, and if the queue
166
+ # refuses again the refresh is broadcast right here: the writes that
167
+ # counted on it are never left without one. The failure is reported to
168
+ # Rails.error, not raised: the write has committed, and the refresh is
169
+ # out.
170
+ #
171
+ # Only a marker that is really there means a refresh is queued. A store
172
+ # that can't hold it (RedisCacheStore answers nil when the write
173
+ # fails, any store may raise) means nothing about the queue, so this
174
+ # write queues its own refresh.
110
175
  def refresh_inbox_of(messager)
111
- Turbo::StreamsChannel.broadcast_refresh_later_to(messager, :chats_inbox)
176
+ return if inbox_refresh_queued?(inbox_refresh_marker(messager))
177
+ return if 2.times.any? { enqueue_inbox_refresh(messager) } # once more if the first fails
178
+
179
+ refresh_inbox_now(messager)
180
+ end
181
+
182
+ # The job's half (Chats::InboxRefreshJob): take the marker down, then
183
+ # broadcast. `method: morph, scroll: preserve` ride on the tag, so the
184
+ # inbox morphs in place whatever the host layout's refresh meta says.
185
+ # No request id: one refresh may answer writes from several requests,
186
+ # and a tab that skipped it as "its own" would miss the others'.
187
+ def refresh_inbox_now(messager)
188
+ begin
189
+ Rails.cache.delete(inbox_refresh_marker(messager))
190
+ rescue StandardError => e
191
+ Rails.error.report(e, handled: true) # the marker expires on its own; the refresh goes out regardless
192
+ end
193
+ Turbo::StreamsChannel.broadcast_stream_to(messager, :chats_inbox, content: INBOX_REFRESH)
112
194
  end
113
195
 
114
- def update_badge_of(messager)
196
+ # The unread badges of +messager+: the nav badge (stream 3) and the
197
+ # dock's (stream 4), with the signal that tells the dock what changed.
198
+ # +conversation+ and +message+ say what happened (both nil for a
199
+ # read); +participant+ is the messager's seat there.
200
+ def update_badge_of(messager, conversation: nil, message: nil, participant: nil)
201
+ # Counted at broadcast-enqueue time: the badge is advisory UI; a
202
+ # count that's seconds stale self-heals on the next event. The SAME
203
+ # count the page renders (`chats_unread_badge`), so what the inbox
204
+ # scope hides never lights the badge. Counted once for both.
205
+ count = messager.unread_chats_count
206
+
115
207
  # REPLACE, not update: the partial renders the whole badge element
116
208
  # (its id included) — an `update` would nest it inside the existing
117
209
  # element and duplicate the id. Replace keeps it idempotent.
@@ -119,29 +211,98 @@ module Chats
119
211
  messager, :chats_badge,
120
212
  target: "chats_unread_badge",
121
213
  partial: BADGE_PARTIAL,
122
- # Counted at broadcast-enqueue time: the badge is advisory UI; a
123
- # count that's seconds stale self-heals on the next event. The
124
- # SAME count the page renders (`chats_unread_badge`), so what the
125
- # inbox scope hides never lights the badge.
126
- locals: { count: messager.unread_chats_count }
214
+ locals: { count: count }
215
+ )
216
+
217
+ update_dock_of(messager, count: count, conversation: conversation, message: message, participant: participant)
218
+ end
219
+
220
+ # The dock's update (stream 4): the badges when +count+ is given, and
221
+ # the signal. A conversation the messager cannot see is never named:
222
+ # its badge count is still truthful, the signal is simply not sent.
223
+ def update_dock_of(messager, count: nil, conversation: nil, message: nil, participant: nil)
224
+ signal = dock_signal(messager, conversation, message, participant)
225
+ return if count.nil? && signal.nil?
226
+
227
+ Turbo::StreamsChannel.broadcast_render_later_to(
228
+ messager, :chats_dock,
229
+ partial: DOCK_UPDATE_PARTIAL,
230
+ locals: { count: count, signal: signal }
127
231
  )
128
232
  end
129
233
 
130
234
  private
131
235
 
236
+ # Whether the queue took the job. A refusal is reported to Rails.error
237
+ # however the adapter says it: perform_later raises, or returns false
238
+ # (a callback aborted it), or a job that wasn't enqueued.
239
+ def enqueue_inbox_refresh(messager)
240
+ job = Chats::InboxRefreshJob.perform_later(messager)
241
+ job = nil unless job.is_a?(ActiveJob::Base) # false: a callback aborted it
242
+ return true if job&.successfully_enqueued?
243
+
244
+ Rails.error.report(job&.enqueue_error || ActiveJob::EnqueueError.new("Chats::InboxRefreshJob was not enqueued"),
245
+ handled: true)
246
+ false
247
+ rescue StandardError => e
248
+ Rails.error.report(e, handled: true)
249
+ false
250
+ end
251
+
252
+ # Whether a refresh is already queued for this marker's messager: the
253
+ # write was refused (false) AND the marker can be read back. Anything
254
+ # else — this write put it up, the store answered nil or raised, the
255
+ # marker vanished in between — is "not that we know of".
256
+ def inbox_refresh_queued?(marker)
257
+ written = Rails.cache.write(marker, true, unless_exist: true, expires_in: INBOX_REFRESH_PENDING)
258
+ written == false && Rails.cache.read(marker).present?
259
+ rescue StandardError => e
260
+ Rails.error.report(e, handled: true)
261
+ false
262
+ end
263
+
264
+ def inbox_refresh_marker(messager)
265
+ "chats:inbox-refresh:#{Chats.messager_key(messager)}"
266
+ end
267
+
132
268
  # New activity: every active participant's inbox refreshes (including
133
- # the sender's — their OTHER tabs/devices need the new ordering; the
134
- # originating tab skips its own refresh via Turbo's request-id dedup),
269
+ # the sender's — their other tabs and devices need the new ordering),
135
270
  # and every participant except the sender gets a fresh unread badge.
136
- def fan_out_inboxes(conversation, skip_badge_for: nil, badges: true)
271
+ # Everyone else — the sender, and every seat on an edit or a delete —
272
+ # gets the dock's list refresh alone.
273
+ def fan_out_inboxes(conversation, skip_badge_for: nil, badges: true, message: nil)
137
274
  conversation.participants.active.includes(:messager).each do |participant|
138
275
  messager = participant.messager
139
276
  next if messager.nil?
140
277
 
141
278
  refresh_inbox_of(messager)
142
- update_badge_of(messager) if badges && messager != skip_badge_for
279
+ if badges && messager != skip_badge_for
280
+ update_badge_of(messager, conversation: conversation, message: message, participant: participant)
281
+ else
282
+ update_dock_of(messager, conversation: conversation)
283
+ end
143
284
  end
144
285
  end
286
+
287
+ # What the dock is told, or nil for nothing at all. A read (no
288
+ # conversation) only reloads the list. A conversation the messager
289
+ # can't see (blocked, scoped out) is never named. A new message the
290
+ # participant would be notified about (Participant#notifiable_for?:
291
+ # not theirs, not muted, not a headless seat) may pop its window and
292
+ # be announced, with who it is from and how much is unread there.
293
+ def dock_signal(messager, conversation, message, participant)
294
+ return {} if conversation.nil?
295
+ return nil unless messager.chats.exists?(conversation.id)
296
+
297
+ signal = { conversation_id: conversation.id }
298
+ return signal unless message && participant&.notifiable_for?(message)
299
+
300
+ signal.merge(
301
+ notify: true,
302
+ sender_name: message.sender ? Chats.display_name_for(message.sender) : conversation.title_for(messager),
303
+ unread_count: participant.unread_count
304
+ )
305
+ end
145
306
  end
146
307
  end
147
308
  end
data/lib/chats/inbox.rb CHANGED
@@ -8,9 +8,11 @@ module Chats
8
8
  # Chats::Inbox.for(alice) # rows, newest activity first
9
9
  # Chats::Inbox.for(alice, query: "madrid") # the search box
10
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
11
12
  #
12
13
  # Rows are `Chats::Conversation | Chats::InboxGroup`, sorted by last
13
- # activity descending. Grouping happens HERE and nowhere else.
14
+ # activity descending. Grouping happens HERE and nowhere else, and so does
15
+ # the rule that a conversation with no messages is never listed.
14
16
  #
15
17
  # == Why `inbox_limit` bounds ROWS, not conversations
16
18
  #
@@ -26,24 +28,30 @@ module Chats
26
28
  class Inbox
27
29
  include Enumerable
28
30
 
29
- attr_reader :viewer, :query, :with
31
+ attr_reader :viewer, :query, :with, :limit
30
32
 
31
33
  class << self
32
34
  # The inbox for +viewer+. Returns a Chats::Inbox, which enumerates its
33
35
  # rows (`to_a` for a plain Array).
34
- def for(viewer, query: nil, with: nil)
35
- new(viewer, query: query, with: with)
36
+ def for(viewer, query: nil, with: nil, limit: nil)
37
+ new(viewer, query: query, with: with, limit: limit)
36
38
  end
37
39
  end
38
40
 
39
- def initialize(viewer, query: nil, with: nil)
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)
40
47
  @viewer = viewer
41
48
  @query = query.to_s.strip.presence
42
49
  @with = with
50
+ @limit = Integer(limit || Chats.config.inbox_limit).clamp(1, Chats.config.inbox_limit)
43
51
  end
44
52
 
45
53
  # Conversation | InboxGroup rows, newest activity first, at most
46
- # `config.inbox_limit` of them.
54
+ # +limit+ of them.
47
55
  def rows
48
56
  @rows ||= build_rows
49
57
  end
@@ -111,16 +119,30 @@ module Chats
111
119
 
112
120
  private
113
121
 
114
- def limit
115
- Chats.config.inbox_limit
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
116
126
  end
117
127
 
118
128
  # The viewer's own inbox relation (`Messager#chats`: `inbox_for` with
119
129
  # `config.inbox_scope` applied, and whatever the host's model adds on
120
130
  # top), memoized so it is built ONCE per inbox however many legs the
121
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.
122
138
  def base_relation
123
- @base_relation ||= viewer.chats.includes(:last_message, :subject, participants: :messager)
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
124
146
  end
125
147
 
126
148
  # The polymorphic types worth splitting out. Empty when nothing stacks
@@ -140,15 +162,15 @@ module Chats
140
162
 
141
163
  def load_conversations
142
164
  if filtered?
143
- @ungrouped_relation = filter_to_counterpart(base_relation).limit(limit)
165
+ @ungrouped_relation = filter_to_counterpart(base_relation).limit(window)
144
166
  @stacked = []
145
167
  elsif grouped_types.empty?
146
- @ungrouped_relation = base_relation.limit(limit)
168
+ @ungrouped_relation = base_relation.limit(window)
147
169
  @stacked = []
148
170
  else
149
171
  stacked = Chats::Conversation.direct.where(id: stacked_seats)
150
- @ungrouped_relation = base_relation.where.not(id: stacked).limit(limit)
151
- recent = base_relation.direct.where(id: stacked_seats).except(:includes).select(:id).limit(limit)
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)
152
174
  # Preserve the recent search window, plus the freshest conversation
153
175
  # of each counterpart. A busy stack cannot consume another stack's
154
176
  # row; at most twice the row limit is materialized.
@@ -183,7 +205,7 @@ module Chats
183
205
  messager_id: viewer.id })
184
206
  .select("chats_conversations.id, #{activity} AS activity_at", ranking)
185
207
  Chats::Conversation.from(ranked, :ranked_stacks).where("stack_rank = 1")
186
- .order(Arel.sql("activity_at DESC, id DESC")).limit(limit).select(:id)
208
+ .order(Arel.sql("activity_at DESC, id DESC")).limit(window).select(:id)
187
209
  end
188
210
 
189
211
  # Only the direct threads shared with one counterpart. Direct only, by
@@ -210,7 +232,8 @@ module Chats
210
232
  (stacks[Chats.messager_key(counterpart)] ||= [counterpart, []]).last << conversation
211
233
  end
212
234
 
213
- rows.concat(stacks.each_value.map { |messager, members| build_group(messager, members) })
235
+ totals = stack_totals(stacks.each_value.map(&:first))
236
+ rows.concat(stacks.each_value.map { |messager, members| build_group(messager, members, totals) })
214
237
  sort_rows(rows).first(limit)
215
238
  end
216
239
 
@@ -226,33 +249,48 @@ module Chats
226
249
  end
227
250
  end
228
251
 
229
- def build_group(messager, members)
230
- totals = stack_totals(messager)
252
+ def build_group(messager, members, totals)
253
+ key = stack_key(messager.class.polymorphic_name, messager.id)
231
254
 
232
255
  Chats::InboxGroup.new(
233
256
  messager: messager,
234
257
  conversations: sort_rows(members),
235
- unread_count: totals[:unread],
236
- open_count: totals[:open]
258
+ unread_count: totals[:unread].fetch(key, 0),
259
+ open_count: totals[:open].fetch(key, 0)
237
260
  )
238
261
  end
239
262
 
240
- # What a stacked row says about the WHOLE stack, in two indexed
241
- # aggregates — independent of how deep the stack is, and of how much of
242
- # it we loaded.
243
- def stack_totals(messager)
244
- scope = viewer.chats.reorder(nil).direct.where(
245
- id: Chats::Participant.select(:conversation_id).where(
246
- messager_type: messager.class.polymorphic_name, messager_id: messager.id
247
- )
248
- )
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")
249
278
 
250
279
  {
251
- open: scope.distinct.count,
252
- unread: scope.unread_by(viewer).reorder(nil).count("chats_messages.id")
280
+ open: keyed(seats.distinct.count("chats_conversations.id")),
281
+ unread: keyed(seats.unread_by(viewer).count("chats_messages.id"))
253
282
  }
254
283
  end
255
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
+
256
294
  # The other party of a DIRECT conversation, when their class asked to be
257
295
  # stacked (`acts_as_messager inbox: :grouped`). Read from the preloaded
258
296
  # participants — no extra query per row.
@@ -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
@@ -184,6 +184,12 @@ module Chats
184
184
  # read it, so a conversation the scope hides is hidden everywhere. Until
185
185
  # 0.4.1 the badge counted through the raw `inbox_for` relation and could
186
186
  # light up for a conversation the inbox would never show.
187
+ #
188
+ # It is also the AUTHORIZATION relation (every engine lookup is
189
+ # `viewer.chats.find(id)`), which is why it includes conversations
190
+ # with no messages yet: the thread you just opened must open. The
191
+ # lists narrow it with `Chats::Conversation.with_messages` (see
192
+ # Chats::Inbox); `chats.with_messages` is what your own lists want too.
187
193
  def chats
188
194
  relation = Chats::Conversation.inbox_for(self)
189
195
  Chats.config.inbox_scope.call(relation, self) || relation
@@ -193,8 +199,10 @@ module Chats
193
199
  # badge counts conversations, not messages — 47 unread messages in one
194
200
  # thread is still "1 thing to deal with").
195
201
  def unread_chats_count
196
- # reorder(nil): the inbox scope orders by a COALESCE expression, which
197
- # PostgreSQL rejects inside a COUNT(DISTINCT …) aggregate.
202
+ # No `with_messages` needed to agree with the inbox: `unread_by` joins
203
+ # the messages, so a conversation with no messages can never be
204
+ # counted. reorder(nil): the inbox scope orders by a COALESCE
205
+ # expression, which PostgreSQL rejects inside a COUNT(DISTINCT …).
198
206
  chats.unread_by(self).reorder(nil).distinct.count
199
207
  end
200
208
 
@@ -59,6 +59,41 @@ module Chats
59
59
  order(Arel.sql("COALESCE(chats_conversations.last_message_at, chats_conversations.created_at) DESC"))
60
60
  }
61
61
 
62
+ # Conversations that have at least one message, system messages and
63
+ # tombstones included (`last_message_id` set). Not "a person wrote here":
64
+ # a conversation holding only a system note, or only a deleted message,
65
+ # has messages. A conversation exists from the moment it is OPENED
66
+ # (`chat_with`, the Message button), and nobody wants an inbox full of
67
+ # the threads they opened and never used — "No messages yet" rows. So
68
+ # every LIST the gem renders (the inbox, its stacks, search, the
69
+ # `?with=` view, the dock's recent list) reads through this scope, while
70
+ # the authorization relation (`Messager#chats`) does NOT: an empty
71
+ # conversation still opens when addressed directly, which is how its
72
+ # first message gets written.
73
+ #
74
+ # Free, because the denormalized pointer already says it: a conversation
75
+ # has a last message exactly when it has messages (the pointer is set on
76
+ # every create and healed on every hard delete; a soft-deleted tombstone
77
+ # is still a message, so a thread whose only message was deleted stays
78
+ # listed, like everywhere else).
79
+ scope :with_messages, -> { where.not(last_message_id: nil) }
80
+
81
+ # Conversations a messager wrote in, and their complement. A narrower
82
+ # question than `with_messages`, which counts every message: a system
83
+ # line is the app talking ("Your ride was cancelled", which the inbox
84
+ # rightly lists), and a tombstone no longer says anything. These count
85
+ # only Chats::Message.human, what is left when both are set aside. For
86
+ # the readers that must not count a thread holding only "Alice joined":
87
+ # ops walls, moderation lists, analytics.
88
+ #
89
+ # One correlated EXISTS / NOT EXISTS, so they compose with any other
90
+ # scope (`alice.chats`, `inbox_for`, `about`, `unread_by`,
91
+ # `with_messages`) and never multiply rows:
92
+ # alice.chats.with_human_messages
93
+ # Chats::Conversation.about(ride).without_human_messages.count # admin-wide
94
+ scope :with_human_messages, -> { where(human_messages_exist) }
95
+ scope :without_human_messages, -> { where.not(human_messages_exist) }
96
+
62
97
  # A messager's inbox: every conversation they're an active participant of
63
98
  # (not left), minus direct threads with someone they're blocked with,
64
99
  # newest activity first. THE hot query — keep it composable and indexed.
@@ -190,6 +225,20 @@ module Chats
190
225
  .group("chats_conversations.id")
191
226
  .count("chats_messages.id")
192
227
  end
228
+
229
+ # EXISTS (SELECT 1 FROM chats_messages WHERE conversation_id =
230
+ # chats_conversations.id AND kind = 'text' AND deleted_at IS NULL),
231
+ # built from Chats::Message.human so the two definitions can never
232
+ # drift, and unscoped so a Message scope the caller happens to be inside
233
+ # never leaks in. Public only because scope bodies run on the relation,
234
+ # which cannot reach a private class method.
235
+ def human_messages_exist # :nodoc:
236
+ messages = Chats::Message.arel_table
237
+ Chats::Message.unscoped.human
238
+ .where(messages[:conversation_id].eq(arel_table[:id]))
239
+ .select(Arel.sql("1"))
240
+ .arel.exists
241
+ end
193
242
  end
194
243
 
195
244
  # --- Predicates & lookups -------------------------------------------------
@@ -329,6 +378,14 @@ module Chats
329
378
  messages.create!(kind: "system", body: body, action: action)
330
379
  end
331
380
 
381
+ # The newest thing a messager wrote here (see `.with_human_messages`), or
382
+ # nil when none did. Unlike +last_message+ it skips system lines and
383
+ # tombstones, and it is a query, not the denormalized pointer: one per
384
+ # call. For a list, batch it with Chats::Message.last_human_in(conversations).
385
+ def last_human_message
386
+ messages.human.recent_first.first
387
+ end
388
+
332
389
  # --- Read state -----------------------------------------------------------
333
390
 
334
391
  def unread_count_for(messager)