chats 0.1.1 → 0.2.0

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +163 -28
  4. data/app/assets/stylesheets/chats.css +44 -0
  5. data/app/controllers/chats/conversations_controller.rb +43 -43
  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 +94 -0
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_group.html.erb +38 -0
  11. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  12. data/app/views/chats/conversations/index.html.erb +39 -9
  13. data/app/views/chats/conversations/show.html.erb +44 -3
  14. data/app/views/chats/messages/_composer.html.erb +4 -0
  15. data/app/views/chats/messages/_message.html.erb +29 -8
  16. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  17. data/config/importmap.rb +2 -1
  18. data/config/locales/en.yml +11 -0
  19. data/config/locales/es.yml +11 -0
  20. data/context7.json +4 -0
  21. data/docs/PRD.md +1 -1
  22. data/docs/campfire_review.md +1 -1
  23. data/gemfiles/rails_7.1.gemfile +1 -0
  24. data/gemfiles/rails_7.2.gemfile +1 -0
  25. data/gemfiles/rails_8.1.gemfile +1 -0
  26. data/lib/chats/configuration.rb +63 -1
  27. data/lib/chats/engine.rb +29 -7
  28. data/lib/chats/errors.rb +16 -0
  29. data/lib/chats/inbox.rb +303 -0
  30. data/lib/chats/inbox_group.rb +75 -0
  31. data/lib/chats/macros.rb +24 -1
  32. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  33. data/lib/chats/models/concerns/messager.rb +81 -2
  34. data/lib/chats/models/conversation.rb +59 -5
  35. data/lib/chats/models/message.rb +67 -3
  36. data/lib/chats/models/participant.rb +59 -0
  37. data/lib/chats/models/reaction.rb +5 -0
  38. data/lib/chats/subscribers.rb +156 -0
  39. data/lib/chats/version.rb +1 -1
  40. data/lib/chats.rb +114 -15
  41. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  42. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  43. data/lib/generators/chats/templates/initializer.rb +95 -14
  44. data/lib/generators/chats/upgrade_generator.rb +48 -0
  45. metadata +12 -2
@@ -8,6 +8,11 @@ es:
8
8
  no_results_title: "Sin resultados"
9
9
  no_results_hint: "No hay nada que coincida con «%{query}»."
10
10
  no_messages: "Sin mensajes todavía"
11
+ filtered_by: "Conversaciones con %{name}"
12
+ clear_filter: "Ver todas las conversaciones"
13
+ group_count:
14
+ one: "1 conversación"
15
+ other: "%{count} conversaciones"
11
16
  you_prefix: "Tú:"
12
17
  thread:
13
18
  back: "Atrás"
@@ -22,6 +27,7 @@ es:
22
27
  yesterday: "Ayer"
23
28
  typing_suffix: "está escribiendo…"
24
29
  new_messages: "Mensajes nuevos"
30
+ see_all: "Ver todas"
25
31
  conversation:
26
32
  empty_title: "Conversación"
27
33
  message:
@@ -30,6 +36,7 @@ es:
30
36
  copy: "Copiar"
31
37
  copied: "¡Copiado!"
32
38
  attachment: "Foto"
39
+ signature: "— %{name}"
33
40
  close_attachment: "Cerrar foto"
34
41
  edit: "Editar"
35
42
  delete: "Eliminar"
@@ -44,6 +51,7 @@ es:
44
51
  placeholder: "Escribe un mensaje…"
45
52
  send: "Enviar"
46
53
  attach: "Adjuntar imágenes"
54
+ locked: "Esta conversación está cerrada."
47
55
  buttons:
48
56
  chat: "Mensaje"
49
57
  flashes:
@@ -67,7 +75,10 @@ es:
67
75
  chats/message:
68
76
  attributes:
69
77
  base:
78
+ locked: "Esta conversación está cerrada."
70
79
  blocked: "No puedes enviar mensajes a esta persona."
80
+ author:
81
+ not_a_messager: "debe ser un mensajero (acts_as_messager)"
71
82
  sender:
72
83
  blank: "es obligatorio"
73
84
  not_a_participant: "no participa en esta conversación"
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/chats",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }
data/docs/PRD.md CHANGED
@@ -18,7 +18,7 @@ Direct messages, group chats, reactions, attachments, read receipts — Hotwire-
18
18
 
19
19
  A gem you add to a Rails app to get **Instagram/X-DM-class** user-to-user messaging without building it again. The happy path is one generator + one `acts_as_messager` line + a mounted engine; the result is a working, real-time, polished inbox. Power users override views, policies, and adapters.
20
20
 
21
- It is **not** a chatbot/LLM framework, not a Slack-clone with workspaces, and not a support-ticketing tool. It is peer-to-peer (and group) human messaging.
21
+ It is **not** a chatbot/LLM framework, not a Slack-clone with workspaces, and not a support-ticketing tool (that is `support_desk`, a separate product gem built on this one — see README). It is peer-to-peer (and group) human messaging.
22
22
 
23
23
  **Why it exists:** every consumer app eventually needs DMs, and everyone rebuilds the same Conversation/Message/Participant/Receipt model, the same Action Cable + Turbo plumbing, and the same "report this message / block this user / filter this text" surface. We already built the moderation half for CarHey; `chats` is the messaging half, and the two snap together.
24
24
 
@@ -11,7 +11,7 @@ with the same facts.
11
11
 
12
12
  | Campfire pattern | Where it landed here | Notes |
13
13
  | --- | --- | --- |
14
- | **Stale-room refresh** (`Rooms::RefreshesController` + `refresh_room_controller.js`): on tab-visible-after-sleep or cable reconnect, fetch `?since=` and append new / replace updated | `ConversationsController#refresh`, `Message.created_since/.updated_since`, thread controller `refreshThread()` | The single biggest reliability pattern in their codebase — mobile WebViews reap WebSockets constantly. We improved the trigger: instead of their dedicated `HeartbeatChannel`, we observe the `connected` attribute turbo-rails already toggles on `<turbo-cable-stream-source>`. Zero new channels. We also added a deep-backlog escape hatch: > 1 page missed answers with a Turbo 8 `refresh` stream action (full morph) instead of splicing arbitrary history. |
14
+ | **Stale-room refresh** (`Rooms::RefreshesController` + `refresh_room_controller.js`): on tab-visible-after-sleep or cable reconnect, fetch `?since=` and append new / replace updated | `ConversationsController#refresh`, `Message.created_since/.updated_since`, thread controller `refreshThread()`; **and the inbox** via `refresh_inbox_controller.js` (same reconnect/visibility triggers, but the recovery action is a Turbo 8 page `refresh` since inbox broadcasts already are page refreshes) | The single biggest reliability pattern in their codebase — mobile WebViews reap WebSockets constantly. We improved the trigger: instead of their dedicated `HeartbeatChannel`, we observe the `connected` attribute turbo-rails already toggles on `<turbo-cable-stream-source>`. Zero new channels. We also added a deep-backlog escape hatch: > 1 page missed answers with a Turbo 8 `refresh` stream action (full morph) instead of splicing arbitrary history. The inbox got the same doctrine (missed inbox refreshes had no recovery before). |
15
15
  | **DOM cap** (`message_paginator.js` `maxMessages: 300`) | Thread controller `trimExcessMessages()` (300 + 20 leeway) | Only trims while the viewer is parked at the bottom. Our pagination is a server-rendered lazy-frame chain (theirs is JS-driven), so trimming also re-plants the keyset anchor frame (`rebuildPaginationAnchor()`) — trimmed history stays reachable on scroll-up with no gaps. |
16
16
  | **Out-of-order arrival handling** (their `messages_controller.js` re-sorts on insert) | Thread controller `ensureChronological()` | Broadcast appends from concurrent host job workers can land out of order. ISO8601 lexicographic compare; equal timestamps keep arrival order. |
17
17
  | **First-unread anchoring** (they page around the first unread; membership unread marker) | The «new messages» divider: `@first_unread_id` computed in `#show` *before* `read!` advances the horizon | We render a divider rather than re-anchoring the page — the thread still opens at the bottom (coordination chats are short; jumping deep into history on open would feel broken at our scale). Backlogs deeper than a page pin the divider to the top of the page. |
@@ -14,6 +14,7 @@ group :development do
14
14
  end
15
15
 
16
16
  group :test do
17
+ gem "json", "~> 2.7"
17
18
  gem "minitest", "~> 6.0"
18
19
  gem "minitest-mock"
19
20
  gem "mocha", "~> 2.0"
@@ -14,6 +14,7 @@ group :development do
14
14
  end
15
15
 
16
16
  group :test do
17
+ gem "json", "~> 2.7"
17
18
  gem "minitest", "~> 6.0"
18
19
  gem "minitest-mock"
19
20
  gem "mocha", "~> 2.0"
@@ -14,6 +14,7 @@ group :development do
14
14
  end
15
15
 
16
16
  group :test do
17
+ gem "json", "~> 2.7"
17
18
  gem "minitest", "~> 6.0"
18
19
  gem "minitest-mock"
19
20
  gem "mocha", "~> 2.0"
@@ -86,6 +86,12 @@ module Chats
86
86
  attr_accessor :messages_per_page, :max_message_length, :max_group_size, :max_attachment_size,
87
87
  :max_attachments_per_message
88
88
 
89
+ # How many conversations the inbox loads (and therefore how deep search
90
+ # and grouping see). The inbox is a "recent activity" surface, not an
91
+ # archive — raise it only if your users really keep hundreds of live
92
+ # threads.
93
+ attr_accessor :inbox_limit
94
+
89
95
  # Per-sender send throttle, enforced with Rails 8's built-in controller
90
96
  # `rate_limit` when available (feature-detected; on Rails 7.1 it's a
91
97
  # no-op). Shape: `{ to: Integer, within: ActiveSupport::Duration }`.
@@ -109,6 +115,12 @@ module Chats
109
115
  # May +creator+ create a group conversation?
110
116
  attr_reader :can_create_group
111
117
 
118
+ # ->(relation, viewer) { relation } — composed into the inbox query
119
+ # before the limit, so a host can hide or re-scope rows without
120
+ # overriding the controller:
121
+ # config.inbox_scope = ->(relation, viewer) { relation.where.not(kind: "group") }
122
+ attr_reader :inbox_scope
123
+
112
124
  # --- Ecosystem seams (procs, no-op defaults) ------------------------------
113
125
 
114
126
  # ->(messager) { ids } — every messager id that can't talk with the given
@@ -130,6 +142,17 @@ module Chats
130
142
  # or variant), or nil to render an initials placeholder.
131
143
  attr_reader :messager_avatar
132
144
 
145
+ # ->(messager) { path_or_url_or_nil } — where a messager's profile lives.
146
+ # The bundled views link names and avatars to it; nil (the default) means
147
+ # no anchor at all, so the gem never assumes a `user_path` exists.
148
+ attr_reader :messager_url
149
+
150
+ # ->(message) { String } — the signature line under a SIGNED message
151
+ # (one written by an author on the sender's behalf — see
152
+ # Chats::Message#signed?). nil (the default) renders the localized
153
+ # "— Author Name".
154
+ attr_reader :message_signature
155
+
133
156
  def initialize
134
157
  @messager_class = "User"
135
158
  @parent_controller = "::ApplicationController"
@@ -152,14 +175,18 @@ module Chats
152
175
  @max_attachment_size = 10 * 1024 * 1024 # 10 MB
153
176
  @max_attachments_per_message = 4
154
177
  @send_rate_limit = { to: 60, within: 60 } # 60 messages per minute per sender
178
+ @inbox_limit = 200
155
179
 
156
180
  @encrypt_messages = false
157
181
 
158
182
  @can_message = ->(_sender, _recipient) { true }
159
183
  @can_create_group = ->(_creator) { true }
184
+ @inbox_scope = ->(relation, _viewer) { relation }
160
185
 
161
186
  @blocked_messager_ids = ->(_messager) { [] }
162
187
  @notifier = ->(_event, **_payload) {}
188
+ @messager_url = ->(_messager) { nil }
189
+ @message_signature = nil
163
190
 
164
191
  @messager_display_name = lambda do |messager|
165
192
  messager.try(:display_name) || messager.try(:name) ||
@@ -232,8 +259,41 @@ module Chats
232
259
  @blocked_messager_ids = ensure_callable(value, "blocked_messager_ids")
233
260
  end
234
261
 
262
+ # DEPRECATED (removed in 1.0): sugar that subscribes one `(event,
263
+ # **payload)` proc to the two events that existed in 0.1.1
264
+ # (:message_created, :conversation_read) — and ONLY those, so an old
265
+ # keyword-specific hook can't start raising on events it was never
266
+ # written for. `Chats.on` supersedes it: many subscribers, per-event
267
+ # payloads, reload-safe keys, and every event.
235
268
  def notifier=(value)
236
- @notifier = ensure_callable(value, "notifier")
269
+ hook = ensure_callable(value, "notifier")
270
+ Chats.deprecator.warn(
271
+ "config.notifier is deprecated and will be removed in chats 1.0. " \
272
+ "It receives #{Chats::Subscribers::LEGACY_NOTIFIER_EVENTS.map(&:inspect).join(" and ")} only; " \
273
+ "the events added in 0.2.0 are Chats.on-only. " \
274
+ "Subscribe with Chats.on(:message_created) { |message| … } instead " \
275
+ "(see the README's \"Events\" section)."
276
+ )
277
+
278
+ Chats::Subscribers::LEGACY_NOTIFIER_EVENTS.each do |event|
279
+ Chats::Subscribers.on(event, key: Chats::Subscribers::NOTIFIER_KEY, style: :event) do |fired, **payload|
280
+ hook.call(fired, **payload)
281
+ end
282
+ end
283
+
284
+ @notifier = hook
285
+ end
286
+
287
+ def inbox_scope=(value)
288
+ @inbox_scope = ensure_callable(value, "inbox_scope")
289
+ end
290
+
291
+ def messager_url=(value)
292
+ @messager_url = ensure_callable(value, "messager_url")
293
+ end
294
+
295
+ def message_signature=(value)
296
+ @message_signature = value.nil? ? nil : ensure_callable(value, "message_signature")
237
297
  end
238
298
 
239
299
  def messager_display_name=(value)
@@ -259,6 +319,8 @@ module Chats
259
319
  raise ConfigurationError, "messages_per_page must be positive (got #{messages_per_page.inspect})"
260
320
  end
261
321
 
322
+ raise ConfigurationError, "inbox_limit must be positive (got #{inbox_limit.inspect})" if inbox_limit.to_i < 1
323
+
262
324
  true
263
325
  end
264
326
 
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