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