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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +166 -0
- data/README.md +163 -28
- data/app/assets/stylesheets/chats.css +44 -0
- data/app/controllers/chats/conversations_controller.rb +43 -43
- data/app/controllers/chats/messages_controller.rb +34 -0
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +94 -0
- data/app/javascript/chats/refresh_inbox_controller.js +86 -0
- data/app/views/chats/conversations/_group.html.erb +38 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
- data/app/views/chats/conversations/index.html.erb +39 -9
- data/app/views/chats/conversations/show.html.erb +44 -3
- data/app/views/chats/messages/_composer.html.erb +4 -0
- data/app/views/chats/messages/_message.html.erb +29 -8
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +11 -0
- data/config/locales/es.yml +11 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/gemfiles/rails_7.1.gemfile +1 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/lib/chats/configuration.rb +63 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +303 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/macros.rb +24 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +81 -2
- data/lib/chats/models/conversation.rb +59 -5
- data/lib/chats/models/message.rb +67 -3
- data/lib/chats/models/participant.rb +59 -0
- data/lib/chats/models/reaction.rb +5 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +114 -15
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
- data/lib/generators/chats/templates/initializer.rb +95 -14
- data/lib/generators/chats/upgrade_generator.rb +48 -0
- metadata +12 -2
data/config/locales/es.yml
CHANGED
|
@@ -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
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
|
|
data/docs/campfire_review.md
CHANGED
|
@@ -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. |
|
data/gemfiles/rails_7.1.gemfile
CHANGED
data/gemfiles/rails_7.2.gemfile
CHANGED
data/gemfiles/rails_8.1.gemfile
CHANGED
data/lib/chats/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
data/lib/chats/inbox.rb
ADDED
|
@@ -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
|