chats 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +233 -0
- data/README.md +214 -28
- data/app/assets/stylesheets/chats.css +86 -0
- data/app/controllers/chats/conversations_controller.rb +39 -42
- 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 +113 -2
- data/app/javascript/chats/refresh_inbox_controller.js +86 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
- data/app/views/chats/conversations/_group.html.erb +41 -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 +48 -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/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +13 -0
- data/config/locales/es.yml +13 -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 +79 -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 +33 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +99 -2
- data/lib/chats/models/conversation.rb +78 -7
- 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 +122 -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 +106 -14
- data/lib/generators/chats/upgrade_generator.rb +48 -0
- metadata +13 -2
data/lib/chats/engine.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Chats
|
|
|
30
30
|
CHATS_LIB = File.expand_path("chats", LIB_ROOT)
|
|
31
31
|
|
|
32
32
|
ZEITWERK_IGNORED = %w[
|
|
33
|
-
version.rb errors.rb configuration.rb engine.rb macros.rb
|
|
33
|
+
version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb
|
|
34
34
|
].freeze
|
|
35
35
|
|
|
36
36
|
initializer "chats.autoload", before: :set_autoload_paths do
|
|
@@ -64,6 +64,13 @@ module Chats
|
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# Hand the gem's deprecator to the app, so `config.active_support.
|
|
68
|
+
# deprecation` (and `deprecators.silence`) govern chats' own deprecation
|
|
69
|
+
# warnings like any other framework's.
|
|
70
|
+
initializer "chats.deprecator" do |app|
|
|
71
|
+
app.deprecators[:chats] = Chats.deprecator if app.respond_to?(:deprecators)
|
|
72
|
+
end
|
|
73
|
+
|
|
67
74
|
# Expose `acts_as_messager` / `acts_as_chat_subject` on every AR model.
|
|
68
75
|
initializer "chats.active_record" do
|
|
69
76
|
ActiveSupport.on_load(:active_record) do
|
|
@@ -71,11 +78,20 @@ module Chats
|
|
|
71
78
|
end
|
|
72
79
|
end
|
|
73
80
|
|
|
74
|
-
#
|
|
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
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# A stack of direct conversations that share one counterpart, shown as a
|
|
5
|
+
# SINGLE inbox row. Built by Chats::Inbox for messagers declared with
|
|
6
|
+
# `acts_as_messager inbox: :grouped` — a support desk, a marketplace
|
|
7
|
+
# storefront, any seat a person ends up with many separate threads with.
|
|
8
|
+
#
|
|
9
|
+
# It quacks like the parts of Chats::Conversation the inbox row needs
|
|
10
|
+
# (+last_message+, +last_message_at+, +unread_count+) so the two row
|
|
11
|
+
# partials stay symmetrical.
|
|
12
|
+
#
|
|
13
|
+
# +unread_count+ and +open_count+ are GLOBAL: they describe the whole
|
|
14
|
+
# stack, however deep it runs, not the bounded window in +conversations+.
|
|
15
|
+
# Chats::Inbox reads them with two indexed aggregates per stack.
|
|
16
|
+
class InboxGroup
|
|
17
|
+
# +conversations+ is the loaded WINDOW of the stack (freshest first,
|
|
18
|
+
# bounded by config.inbox_limit); +open_count+ and +unread_count+ describe
|
|
19
|
+
# the whole stack, however deep it runs.
|
|
20
|
+
attr_reader :messager, :conversations, :unread_count, :open_count
|
|
21
|
+
|
|
22
|
+
def initialize(messager:, conversations:, unread_count: 0, open_count: nil)
|
|
23
|
+
@messager = messager
|
|
24
|
+
@conversations = conversations
|
|
25
|
+
@unread_count = unread_count
|
|
26
|
+
@open_count = open_count || conversations.size
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A stack of one is really just a conversation: the row links straight to
|
|
30
|
+
# it, and the thread carries a "see all" link back to the stack.
|
|
31
|
+
def single?
|
|
32
|
+
open_count == 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The freshest conversation in the stack — what the row previews, and
|
|
36
|
+
# what it links to when the stack holds exactly one.
|
|
37
|
+
def conversation
|
|
38
|
+
conversations.first
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The stack's most recent message (the row's preview line).
|
|
42
|
+
def last_message
|
|
43
|
+
conversation&.last_message
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The sort key the inbox orders rows by: the freshest activity in the
|
|
47
|
+
# stack (mirrors Conversation.recent_first's COALESCE).
|
|
48
|
+
def last_message_at
|
|
49
|
+
conversations.filter_map { |c| c.last_message_at || c.created_at }.max
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether the stack has anything unread in it, anywhere.
|
|
53
|
+
def unread?
|
|
54
|
+
unread_count.positive?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What the row is called. A stack is named after its counterpart from
|
|
58
|
+
# every seat, so the viewer is accepted and ignored — the signature
|
|
59
|
+
# matches Chats::Conversation#title_for so both row partials can call it.
|
|
60
|
+
def title_for(_viewer)
|
|
61
|
+
Chats.display_name_for(messager)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Stable DOM id for the row (no AR record to derive one from).
|
|
65
|
+
def dom_id
|
|
66
|
+
"chats_inbox_group_#{messager.class.polymorphic_name.underscore.tr("/", "_")}_#{messager.id}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The signed, purpose-scoped GlobalID that filters the inbox to this
|
|
70
|
+
# stack (`GET /conversations?with=…`).
|
|
71
|
+
def with_sgid
|
|
72
|
+
Chats.inbox_with_sgid(messager)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/chats/macros.rb
CHANGED
|
@@ -17,8 +17,40 @@ module Chats
|
|
|
17
17
|
# lives in Chats::Messager / Chats::ChatSubject so it's discoverable,
|
|
18
18
|
# testable, and `include`-able directly when a host prefers that style.
|
|
19
19
|
module Macros
|
|
20
|
-
|
|
20
|
+
# Options (all optional; the defaults are 0.1.x behaviour):
|
|
21
|
+
#
|
|
22
|
+
# notifications: false this messager is never notifiable — a support
|
|
23
|
+
# desk, a bot, an org mailbox. `Participant#
|
|
24
|
+
# notifiable_for?` says no, so hosts stop
|
|
25
|
+
# branching on class in every notifier.
|
|
26
|
+
# blockable: false block/report affordances don't apply to it, so
|
|
27
|
+
# the bundled views hide them.
|
|
28
|
+
# inbox: :grouped every direct conversation with this messager
|
|
29
|
+
# stacks into ONE inbox row (see Chats::Inbox).
|
|
30
|
+
# group_path: ->(viewer) { } where that stacked row links to; defaults
|
|
31
|
+
# to the filtered inbox (`?with=<sgid>`).
|
|
32
|
+
# verified: true this messager is an OFFICIAL account — a support
|
|
33
|
+
# desk, an organization, a brand. The bundled views
|
|
34
|
+
# mark its name with the rosette everyone already
|
|
35
|
+
# reads as "verified". Strictly true/false: a badge
|
|
36
|
+
# is a trust claim, so a stray "false" string must
|
|
37
|
+
# raise rather than quietly verify.
|
|
38
|
+
#
|
|
39
|
+
# class Desk < ApplicationRecord
|
|
40
|
+
# acts_as_messager notifications: false, blockable: false, inbox: :grouped,
|
|
41
|
+
# verified: true
|
|
42
|
+
# end
|
|
43
|
+
def acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil,
|
|
44
|
+
verified: false)
|
|
21
45
|
include Chats::Messager
|
|
46
|
+
|
|
47
|
+
self.chat_options = Chats::Messager.normalize_options(
|
|
48
|
+
notifications: notifications,
|
|
49
|
+
blockable: blockable,
|
|
50
|
+
inbox: inbox,
|
|
51
|
+
group_path: group_path,
|
|
52
|
+
verified: verified
|
|
53
|
+
)
|
|
22
54
|
end
|
|
23
55
|
|
|
24
56
|
def acts_as_chat_subject
|
|
@@ -31,5 +31,28 @@ module Chats
|
|
|
31
31
|
def chat_subject_label
|
|
32
32
|
"#{self.class.model_name.human} #{id}"
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
# Whether conversations ABOUT this record still accept messages. The
|
|
36
|
+
# subject already owns the conversation's meaning, so it owns its
|
|
37
|
+
# openness too — a closed ticket, a delivered order, an archived listing:
|
|
38
|
+
#
|
|
39
|
+
# class Ticket < ApplicationRecord
|
|
40
|
+
# acts_as_chat_subject
|
|
41
|
+
# def chat_locked? = closed?
|
|
42
|
+
# def chat_locked_notice = "This ticket is closed. Reply to reopen it."
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# System messages are exempt (the host can always post "Ticket closed"),
|
|
46
|
+
# and locking is a WRITE rule only: the thread stays readable. See
|
|
47
|
+
# Chats::Conversation#locked?.
|
|
48
|
+
def chat_locked?
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The sentence shown where the composer would be. Nil falls back to the
|
|
53
|
+
# gem's localized "This conversation is closed."
|
|
54
|
+
def chat_locked_notice
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
34
57
|
end
|
|
35
58
|
end
|
|
@@ -16,7 +16,93 @@ module Chats
|
|
|
16
16
|
module Messager
|
|
17
17
|
extend ActiveSupport::Concern
|
|
18
18
|
|
|
19
|
+
# What `acts_as_messager` declares about a messager class. The defaults
|
|
20
|
+
# are exactly 0.1.x behaviour, so a bare `acts_as_messager` is unchanged.
|
|
21
|
+
DEFAULT_CHAT_OPTIONS = {
|
|
22
|
+
notifications: true,
|
|
23
|
+
blockable: true,
|
|
24
|
+
inbox: :default,
|
|
25
|
+
group_path: nil,
|
|
26
|
+
verified: false
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
INBOX_MODES = %i[default grouped].freeze
|
|
30
|
+
|
|
31
|
+
# Validate + freeze the macro's options, failing at BOOT with a plain
|
|
32
|
+
# English message rather than at 3am with a NoMethodError.
|
|
33
|
+
def self.normalize_options(notifications:, blockable:, inbox:, group_path:, verified: false)
|
|
34
|
+
inbox = inbox.to_sym
|
|
35
|
+
unless INBOX_MODES.include?(inbox)
|
|
36
|
+
raise Chats::ConfigurationError,
|
|
37
|
+
"acts_as_messager inbox: must be one of #{INBOX_MODES.inspect}, got #{inbox.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if group_path && !group_path.respond_to?(:call)
|
|
41
|
+
raise Chats::ConfigurationError,
|
|
42
|
+
"acts_as_messager group_path: must respond to #call (a proc/lambda), got #{group_path.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Deliberately STRICTER than its boolean neighbours, which coerce with
|
|
46
|
+
# `!!`. "Official account" is a trust claim shown to everyone who talks
|
|
47
|
+
# to this messager, so `verified: "false"` (an ENV var, a YAML
|
|
48
|
+
# round-trip) has to fail at boot rather than quietly verify it.
|
|
49
|
+
unless [true, false].include?(verified)
|
|
50
|
+
raise Chats::ConfigurationError,
|
|
51
|
+
"acts_as_messager verified: must be true or false, got #{verified.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
notifications: !!notifications,
|
|
56
|
+
blockable: !!blockable,
|
|
57
|
+
inbox: inbox,
|
|
58
|
+
group_path: group_path,
|
|
59
|
+
verified: verified
|
|
60
|
+
}.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class_methods do
|
|
64
|
+
# True unless declared with `acts_as_messager notifications: false`.
|
|
65
|
+
def chat_notifications?
|
|
66
|
+
chat_options[:notifications]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# True unless declared with `acts_as_messager blockable: false`.
|
|
70
|
+
def chat_blockable?
|
|
71
|
+
chat_options[:blockable]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# :default | :grouped (see Chats::Inbox).
|
|
75
|
+
def chat_inbox_mode
|
|
76
|
+
chat_options[:inbox]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Whether every direct conversation with this messager folds into ONE
|
|
80
|
+
# inbox row (see Chats::InboxGroup).
|
|
81
|
+
def chat_grouped_inbox?
|
|
82
|
+
chat_inbox_mode == :grouped
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The `group_path:` callable, or nil (then the stacked row links to the
|
|
86
|
+
# filtered inbox).
|
|
87
|
+
def chat_group_path
|
|
88
|
+
chat_options[:group_path]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# True when declared with `acts_as_messager verified: true` — an
|
|
92
|
+
# OFFICIAL account (a support desk, an organization, a brand). The
|
|
93
|
+
# bundled views badge its name wherever they show it.
|
|
94
|
+
def chat_verified?
|
|
95
|
+
chat_options[:verified]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
19
99
|
included do
|
|
100
|
+
# Declared by `acts_as_messager`; a plain `include Chats::Messager`
|
|
101
|
+
# gets the defaults. class_attribute so STI subclasses inherit it.
|
|
102
|
+
class_attribute :chat_options,
|
|
103
|
+
instance_accessor: false,
|
|
104
|
+
default: Chats::Messager::DEFAULT_CHAT_OPTIONS
|
|
105
|
+
|
|
20
106
|
has_many :chat_participations,
|
|
21
107
|
class_name: "Chats::Participant",
|
|
22
108
|
as: :messager,
|
|
@@ -63,14 +149,25 @@ module Chats
|
|
|
63
149
|
# alice.message!(bob, "are you coming?")
|
|
64
150
|
# alice.message!(bob, "about the ride", about: ride)
|
|
65
151
|
# alice.message!(conversation, "hi all!", files: [photo])
|
|
66
|
-
|
|
152
|
+
#
|
|
153
|
+
# `author:` is the human (or bot) writing on the SENDER's behalf — an
|
|
154
|
+
# agent answering from a support desk seat. It signs the bubble; the
|
|
155
|
+
# sender stays the conversation identity. See Chats::Message#signed?.
|
|
156
|
+
#
|
|
157
|
+
# desk.message!(alice, "On it!", author: lucia)
|
|
158
|
+
def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil)
|
|
159
|
+
if author && !Chats.messager_class?(author.class)
|
|
160
|
+
raise Chats::NotAllowedError,
|
|
161
|
+
"author must be a messager (acts_as_messager), got #{author.class.name}"
|
|
162
|
+
end
|
|
163
|
+
|
|
67
164
|
conversation =
|
|
68
165
|
case target
|
|
69
166
|
when Chats::Conversation then target
|
|
70
167
|
else chat_with(target, about: about)
|
|
71
168
|
end
|
|
72
169
|
|
|
73
|
-
attributes = { sender: self, body: body, reply_to: reply_to }
|
|
170
|
+
attributes = { sender: self, body: body, reply_to: reply_to, author: author }
|
|
74
171
|
attributes[:files] = files if files.present?
|
|
75
172
|
conversation.messages.create!(**attributes)
|
|
76
173
|
end
|