chats 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +349 -0
  3. data/README.md +91 -8
  4. data/app/assets/stylesheets/chats.css +114 -0
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +305 -63
  7. data/app/helpers/chats/engine_helper.rb +27 -4
  8. data/app/javascript/chats/dock_controller.js +54 -4
  9. data/app/javascript/chats/recovery.js +539 -0
  10. data/app/javascript/chats/refresh_inbox_controller.js +55 -65
  11. data/app/javascript/chats/split_controller.js +97 -0
  12. data/app/javascript/chats/thread_controller.js +245 -54
  13. data/app/javascript/chats/unread_badge_controller.js +24 -0
  14. data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
  15. data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
  16. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  17. data/app/views/chats/conversations/_group.html.erb +20 -4
  18. data/app/views/chats/conversations/_history.html.erb +54 -0
  19. data/app/views/chats/conversations/_thread.html.erb +29 -54
  20. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  21. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  22. data/app/views/chats/conversations/index.html.erb +91 -15
  23. data/app/views/chats/conversations/pane.html.erb +12 -0
  24. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  25. data/app/views/chats/conversations/show.html.erb +10 -65
  26. data/app/views/chats/dock/_dock.html.erb +14 -5
  27. data/app/views/chats/messages/_message.html.erb +8 -6
  28. data/chats-0.3.2.gem +0 -0
  29. data/chats-0.4.0.gem +0 -0
  30. data/chats-0.4.1.gem +0 -0
  31. data/chats-0.4.2.gem +0 -0
  32. data/chats-0.5.0.gem +0 -0
  33. data/chats-0.5.1.gem +0 -0
  34. data/config/importmap.rb +7 -0
  35. data/config/locales/en.yml +1 -0
  36. data/config/locales/es.yml +1 -0
  37. data/config/routes.rb +3 -0
  38. data/docs/screenshots/split-inbox-empty.png +0 -0
  39. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  40. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  41. data/docs/screenshots/split-inbox.png +0 -0
  42. data/lib/chats/broadcasts.rb +102 -7
  43. data/lib/chats/configuration.rb +23 -0
  44. data/lib/chats/inbox_refresh_job.rb +14 -0
  45. data/lib/chats/models/conversation.rb +38 -0
  46. data/lib/chats/models/message.rb +98 -9
  47. data/lib/chats/models/reaction.rb +13 -0
  48. data/lib/chats/version.rb +1 -1
  49. data/lib/generators/chats/templates/initializer.rb +9 -0
  50. metadata +21 -1
@@ -1,6 +1,9 @@
1
- <%# The thread page: its own header around the shared thread body
2
- (chats/conversations/_thread, which a floating dock window renders too —
3
- see that partial for how the live thread works). %>
1
+ <%# The thread page under `config.inbox_layout = :stacked`: its header
2
+ (chats/conversations/_thread_header) over the shared thread body
3
+ (chats/conversations/_thread, which a floating dock window and the
4
+ two-pane page's conversation pane render too — see that partial for how
5
+ the live thread works). Under the default `:split`, /messages/:id is
6
+ the two-pane page instead (index.html.erb). %>
4
7
  <%= chats_styles %>
5
8
 
6
9
  <%= render layout: "chats/conversations/thread",
@@ -9,67 +12,9 @@
9
12
  messages: @messages,
10
13
  more: @more_messages,
11
14
  first_unread_id: @first_unread_id,
12
- counterpart: chats_counterpart
15
+ counterpart: chats_counterpart,
16
+ read_pending: @read_pending
13
17
  } do %>
14
- <header class="chats-thread__header">
15
- <%= link_to root_path, class: "chats-thread__back", "aria-label": t("chats.thread.back") do %>
16
- <span aria-hidden="true">‹</span>
17
- <% end %>
18
-
19
- <%# The counterpart is already resolved for the title, the profile link
20
- and the stack link — reuse it here instead of asking again. %>
21
- <% if chats_counterpart %>
22
- <%= chats_messager_avatar(chats_counterpart) %>
23
- <% else %>
24
- <%= chats_conversation_avatar(@conversation, chats_current_messager) %>
25
- <% end %>
26
-
27
- <div class="chats-thread__identity">
28
- <%# Both arms wrap the name in .chats-thread__name: the <h1> is a flex
29
- row (the badge is its second child), and only a real element can
30
- carry the ellipsis. %>
31
- <h1 class="chats-thread__title">
32
- <% if chats_counterpart %>
33
- <%= chats_messager_name(chats_counterpart, css_class: "chats-thread__name") %>
34
- <%= chats_verified_badge(chats_counterpart) %>
35
- <% else %>
36
- <span class="chats-thread__name"><%= @conversation.title_for(chats_current_messager) %></span>
37
- <% end %>
38
- </h1>
39
- <% if @conversation.subject_label %>
40
- <p class="chats-thread__subject"><%= @conversation.subject_label %></p>
41
- <% end %>
42
- <%# A stacked counterpart's thread is one of many: offer the way back
43
- to the whole stack (chats' filtered inbox, or the host's own
44
- screen via `group_path:`). %>
45
- <% if Chats.grouped_inbox?(chats_counterpart) %>
46
- <p class="chats-thread__see-all">
47
- <%= link_to t("chats.thread.see_all"), chats_group_path_for(chats_counterpart), class: "chats-thread__see-all-link" %>
48
- </p>
49
- <% end %>
50
- </div>
51
-
52
- <details class="chats-menu">
53
- <summary class="chats-menu__trigger" aria-label="<%= t("chats.thread.options") %>">⋯</summary>
54
- <div class="chats-menu__panel">
55
- <% if @participant&.muted? %>
56
- <%= button_to t("chats.thread.unmute"), unmute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
57
- <% else %>
58
- <%= button_to t("chats.thread.mute"), mute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
59
- <% end %>
60
- <% if @conversation.group? %>
61
- <%= button_to t("chats.thread.leave"), leave_conversation_path(@conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
62
- <% end %>
63
- <%# Slot: the host's own thread actions (block, report, archive…).
64
- `blockable` is false for messagers declared
65
- `acts_as_messager blockable: false`, so safety affordances
66
- disappear against a support desk without any class check. %>
67
- <%= chats_slot :conversation_header_actions,
68
- conversation: @conversation,
69
- viewer: chats_current_messager,
70
- counterpart: chats_counterpart,
71
- blockable: chats_blockable?(chats_counterpart) %>
72
- </div>
73
- </details>
74
- </header>
18
+ <%= render "chats/conversations/thread_header",
19
+ conversation: @conversation, participant: @participant, counterpart: chats_counterpart %>
75
20
  <% end %>
@@ -32,9 +32,12 @@
32
32
  "chats--dock-everywhere-value": everywhere,
33
33
  "chats--dock-window-url-value": chats_routes.conversation_path("__id__", window: 1),
34
34
  "chats--dock-record-key-value": Chats::Conversation.model_name.param_key,
35
+ "chats--dock-badge-url-value": chats_routes.badge_conversations_path,
35
36
  "chats--dock-announcement-value": t("chats.dock.new_message_from", name: "%{name}"),
36
- # Plural forms uninterpolated: the controller fills %{count} in.
37
- "chats--dock-window-unread-value": I18n.t("chats.dock.window_unread").to_json,
37
+ # Every plural form the locale has (CLDR: zero, one, two, few, many,
38
+ # other), uninterpolated: the controller picks one with
39
+ # Intl.PluralRules and fills %{count} in.
40
+ "chats--dock-window-unread-value": chats_plural_forms("chats.dock.window_unread").to_json,
38
41
  action: "keydown->chats--dock#keydown " \
39
42
  "focusin->chats--dock#engage pointerdown->chats--dock#engage wheel->chats--dock#engage " \
40
43
  "touchstart->chats--dock#engage " \
@@ -46,7 +49,8 @@
46
49
  "input->chats--dock#typed " \
47
50
  "click@document->chats--dock#delegate " \
48
51
  "turbo:before-stream-render@document->chats--dock#beforeStreamRender " \
49
- "turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged"
52
+ "turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged " \
53
+ "turbo:frame-load@document->chats--dock#pageFrameLoaded"
50
54
  } do %>
51
55
  <%= turbo_stream_from viewer, :chats_dock %>
52
56
  <%= render "chats/dock/signal" %>
@@ -88,7 +92,8 @@
88
92
  <%= form_with url: chats_routes.conversations_path,
89
93
  method: :get,
90
94
  class: "chats-search chats-dock__search",
91
- role: "search",
95
+ # `html:`: form_with drops attributes it doesn't know (role among them).
96
+ html: { role: "search" },
92
97
  data: {
93
98
  controller: "chats--debounced-submit",
94
99
  action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
@@ -106,10 +111,14 @@
106
111
  <% end %>
107
112
 
108
113
  <%# Lazy: nothing is fetched until the panel first opens; until then,
109
- four still rows. The controller reloads it when the signal says. %>
114
+ four still rows. The controller reloads it when the signal says,
115
+ and when its stream may have missed a signal. `refresh: :morph`:
116
+ a reload morphs the rows in place, so the list keeps its scroll
117
+ position, and it reloads its own src, so a search stays. %>
110
118
  <%= turbo_frame_tag "chats_dock_list",
111
119
  src: chats_routes.dock_conversations_path(recent: recent),
112
120
  loading: :lazy,
121
+ refresh: :morph,
113
122
  target: "_top",
114
123
  class: "chats-dock__results",
115
124
  # A click here opens a window, never the page: hovering a row
@@ -15,12 +15,14 @@
15
15
  data-chats--thread-target="message"
16
16
  data-sender-key="<%= message.sender_key %>"
17
17
  data-timestamp="<%= message.created_at.iso8601 %>"
18
- <%# updated-at-ms: the thread controller tracks the max across bubbles as
19
- its `?since=` cursor for the stale-thread refresh — updated_at (not
20
- created_at) so edits/tombstones that happened while asleep are caught
21
- too. `.ceil` biases the cursor PAST this row's sub-millisecond tail,
22
- so the strictly-greater server query can't re-fetch it. %>
23
- data-updated-at-ms="<%= (message.updated_at.to_f * 1000).ceil %>">
18
+ <%# The stale-thread catch-up's cursors, to the microsecond the
19
+ database keeps, with the id that breaks a tie: the newest creation
20
+ on screen decides what is appended (`after`), the newest change
21
+ what is replaced (`changed`), and the oldest bubble's creation is
22
+ the `from` of the list of what still exists (hard deletes). %>
23
+ data-message-id="<%= message.id %>"
24
+ data-created-at-us="<%= (message.created_at.to_i * 1_000_000) + message.created_at.usec %>"
25
+ data-updated-at-us="<%= (message.updated_at.to_i * 1_000_000) + message.updated_at.usec %>">
24
26
 
25
27
  <% if message.system? %>
26
28
  <div class="chats-message__system<%= " chats-message__system--with-action" if message.action? %>">
data/chats-0.3.2.gem ADDED
Binary file
data/chats-0.4.0.gem ADDED
Binary file
data/chats-0.4.1.gem ADDED
Binary file
data/chats-0.4.2.gem ADDED
Binary file
data/chats-0.5.0.gem ADDED
Binary file
data/chats-0.5.1.gem ADDED
Binary file
data/config/importmap.rb CHANGED
@@ -16,3 +16,10 @@ pin "controllers/chats/composer_controller", to: "chats/composer_controller.js"
16
16
  pin "controllers/chats/debounced_submit_controller", to: "chats/debounced_submit_controller.js"
17
17
  pin "controllers/chats/refresh_inbox_controller", to: "chats/refresh_inbox_controller.js"
18
18
  pin "controllers/chats/dock_controller", to: "chats/dock_controller.js"
19
+ pin "controllers/chats/unread_badge_controller", to: "chats/unread_badge_controller.js"
20
+ pin "controllers/chats/split_controller", to: "chats/split_controller.js"
21
+
22
+ # Not a controller: what the inbox, thread, badge and dock controllers share
23
+ # to catch up on broadcasts they missed. Outside controllers/ so the eager
24
+ # loader never tries to register it.
25
+ pin "chats/recovery", to: "chats/recovery.js"
@@ -8,6 +8,7 @@ en:
8
8
  no_results_title: "No results"
9
9
  no_results_hint: "Nothing matched “%{query}”."
10
10
  no_messages: "No messages yet"
11
+ select_hint: "Select a conversation"
11
12
  filtered_by: "Conversations with %{name}"
12
13
  clear_filter: "Show all conversations"
13
14
  group_count:
@@ -8,6 +8,7 @@ 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
+ select_hint: "Selecciona una conversación"
11
12
  filtered_by: "Conversaciones con %{name}"
12
13
  clear_filter: "Ver todas las conversaciones"
13
14
  group_count:
data/config/routes.rb CHANGED
@@ -9,6 +9,9 @@ Chats::Engine.routes.draw do
9
9
  # frame. A collection route, so it is drawn before /:id and never read as
10
10
  # a conversation id.
11
11
  get :dock, on: :collection
12
+ # Every unread badge on the page, fresh (`chats_unread_badge` and the
13
+ # dock's), for a page that may have missed the badge streams.
14
+ get :badge, on: :collection
12
15
 
13
16
  member do
14
17
  post :read # advance the viewer's read horizon (mark as read)
Binary file
@@ -20,9 +20,8 @@ module Chats
20
20
  # broadcast would force per-viewer renders of per-viewer partials;
21
21
  # a refresh makes each client re-request the page and get a correct,
22
22
  # personalized render with morphing + scroll preservation for free.
23
- # Turbo debounces refreshes per stream and tags them with the
24
- # originating request id so the tab that caused the change skips its
25
- # own refresh. https://turbo.hotwired.dev/handbook/streams
23
+ # At most one is queued per messager at a time (see
24
+ # refresh_inbox_of). https://turbo.hotwired.dev/handbook/streams
26
25
  #
27
26
  # 3. The per-messager BADGE stream (`[messager, :chats_badge]`,
28
27
  # subscribable from ANY page via the `chats_unread_badge` helper —
@@ -49,7 +48,16 @@ module Chats
49
48
  #
50
49
  # Everything uses the `_later` variants (rendering happens in background
51
50
  # jobs, never inline in the request) except `message_destroyed`, where the
52
- # 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.
53
61
  module Broadcasts
54
62
  extend ActionView::RecordIdentifier # dom_id
55
63
 
@@ -57,6 +65,10 @@ module Chats
57
65
  BADGE_PARTIAL = "chats/shared/unread_badge"
58
66
  DOCK_UPDATE_PARTIAL = "chats/dock/update"
59
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
60
72
 
61
73
  class << self
62
74
  def message_created(message)
@@ -125,8 +137,60 @@ module Chats
125
137
  )
126
138
  end
127
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.
128
175
  def refresh_inbox_of(messager)
129
- Turbo::StreamsChannel.broadcast_refresh_later_to(messager, :chats_inbox)
176
+ return if inbox_refresh_queued?(inbox_refresh_marker(messager))
177
+ return if 2.times.any? { enqueue_inbox_refresh(messager) } # once more if the first fails
178
+
179
+ refresh_inbox_now(messager)
180
+ end
181
+
182
+ # The job's half (Chats::InboxRefreshJob): take the marker down, then
183
+ # broadcast. `method: morph, scroll: preserve` ride on the tag, so the
184
+ # inbox morphs in place whatever the host layout's refresh meta says.
185
+ # No request id: one refresh may answer writes from several requests,
186
+ # and a tab that skipped it as "its own" would miss the others'.
187
+ def refresh_inbox_now(messager)
188
+ begin
189
+ Rails.cache.delete(inbox_refresh_marker(messager))
190
+ rescue StandardError => e
191
+ Rails.error.report(e, handled: true) # the marker expires on its own; the refresh goes out regardless
192
+ end
193
+ Turbo::StreamsChannel.broadcast_stream_to(messager, :chats_inbox, content: INBOX_REFRESH)
130
194
  end
131
195
 
132
196
  # The unread badges of +messager+: the nav badge (stream 3) and the
@@ -169,9 +233,40 @@ module Chats
169
233
 
170
234
  private
171
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
+
172
268
  # New activity: every active participant's inbox refreshes (including
173
- # the sender's — their OTHER tabs/devices need the new ordering; the
174
- # 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),
175
270
  # and every participant except the sender gets a fresh unread badge.
176
271
  # Everyone else — the sender, and every seat on an edit or a delete —
177
272
  # gets the dock's list refresh alone.
@@ -15,6 +15,7 @@ module Chats
15
15
  class Configuration
16
16
  ATTACHMENT_MODES = [false, :images, :any].freeze
17
17
  DELETION_MODES = [false, :soft, :hard].freeze
18
+ INBOX_LAYOUTS = %i[split stacked].freeze
18
19
 
19
20
  # --- Host integration -----------------------------------------------------
20
21
 
@@ -92,6 +93,15 @@ module Chats
92
93
  # threads.
93
94
  attr_accessor :inbox_limit
94
95
 
96
+ # How the inbox and a conversation share the screen. `:split` (the
97
+ # default): on screens at least 700px wide, /messages is two panes, the
98
+ # conversation list on the left and the open conversation on the right,
99
+ # and /messages/:id is that same page with the conversation open
100
+ # (LinkedIn's messaging page); narrower, the list and the thread are
101
+ # separate pages. `:stacked`: separate pages at every width, as before
102
+ # 0.6.
103
+ attr_reader :inbox_layout
104
+
95
105
  # Per-sender send throttle, enforced with Rails 8's built-in controller
96
106
  # `rate_limit` when available (feature-detected; on Rails 7.1 it's a
97
107
  # no-op). Shape: `{ to: Integer, within: ActiveSupport::Duration }`.
@@ -187,6 +197,7 @@ module Chats
187
197
  @max_attachments_per_message = 4
188
198
  @send_rate_limit = { to: 60, within: 60 } # 60 messages per minute per sender
189
199
  @inbox_limit = 200
200
+ @inbox_layout = :split
190
201
 
191
202
  @encrypt_messages = false
192
203
 
@@ -244,6 +255,18 @@ module Chats
244
255
  @deletion = normalized
245
256
  end
246
257
 
258
+ def inbox_layout=(value)
259
+ normalized = value.respond_to?(:to_sym) ? value.to_sym : value
260
+ unless INBOX_LAYOUTS.include?(normalized)
261
+ raise ConfigurationError, "inbox_layout must be one of #{INBOX_LAYOUTS.inspect}, got #{value.inspect}"
262
+ end
263
+
264
+ @inbox_layout = normalized
265
+ end
266
+
267
+ # Whether the inbox and the open conversation are one two-pane page.
268
+ def split_inbox? = inbox_layout == :split
269
+
247
270
  def send_rate_limit=(value)
248
271
  if value.nil?
249
272
  @send_rate_limit = nil
@@ -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
@@ -78,6 +78,22 @@ module Chats
78
78
  # listed, like everywhere else).
79
79
  scope :with_messages, -> { where.not(last_message_id: nil) }
80
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
+
81
97
  # A messager's inbox: every conversation they're an active participant of
82
98
  # (not left), minus direct threads with someone they're blocked with,
83
99
  # newest activity first. THE hot query — keep it composable and indexed.
@@ -209,6 +225,20 @@ module Chats
209
225
  .group("chats_conversations.id")
210
226
  .count("chats_messages.id")
211
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
212
242
  end
213
243
 
214
244
  # --- Predicates & lookups -------------------------------------------------
@@ -348,6 +378,14 @@ module Chats
348
378
  messages.create!(kind: "system", body: body, action: action)
349
379
  end
350
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
+
351
389
  # --- Read state -----------------------------------------------------------
352
390
 
353
391
  def unread_count_for(messager)
@@ -81,6 +81,77 @@ module Chats
81
81
  scope :oldest_first, -> { order(created_at: :asc, id: :asc) }
82
82
  scope :visible, -> { where(deleted_at: nil) }
83
83
 
84
+ # What messagers wrote: text messages that still stand. "Human" is the
85
+ # gem's word for a text message, written by a messager, person or not
86
+ # (a bot's or a headless desk's message counts), as opposed to a system
87
+ # line ("Alice joined", "Your ride was cancelled"), which is the app
88
+ # talking. A tombstone no longer says anything. A photo with no caption
89
+ # is still a text message and still counts. The inbox does NOT read through this,
90
+ # on purpose — see README "Real conversations vs system-only threads".
91
+ scope :human, -> { where(kind: "text").visible }
92
+
93
+ # The newest human message of each given conversation, at most one row
94
+ # per conversation, in ONE query: the batch twin of
95
+ # Conversation#last_human_message for lists (admin walls, moderation
96
+ # queues). +conversations+ is a page of records, ids, a single record, or
97
+ # a Chats::Conversation relation — an unloaded relation is read as a
98
+ # subquery, never loaded first, and without its `includes`/`preload`/
99
+ # `eager_load` (they load records; a subquery has none to load). So a
100
+ # relation that FILTERS on an associated table must join it explicitly
101
+ # (`joins` / `left_joins`): `includes(:x).references(:x).where(x: …)`
102
+ # loses the join and raises. Or pass the loaded page, or its ids.
103
+ #
104
+ # The result is a relation, so the columns can be narrowed:
105
+ #
106
+ # Chats::Message.last_human_in(conversations)
107
+ # .select(:id, :conversation_id, :created_at)
108
+ # .index_by(&:conversation_id) # { id => message }
109
+ #
110
+ # A conversation nobody wrote in has no row. ROW_NUMBER works on every
111
+ # supported adapter (PostgreSQL, SQLite and MySQL 8), the same ranking
112
+ # Chats::Inbox uses for its stacks. The ranked subquery is built unscoped,
113
+ # so neither a scope it is called on nor a `.select` chained onto the
114
+ # result leaks into it.
115
+ scope :last_human_in, lambda { |conversations|
116
+ ids = Chats::Message.conversation_ids_in(conversations)
117
+ next none if ids.is_a?(Array) && ids.empty?
118
+
119
+ ranking = "ROW_NUMBER() OVER (PARTITION BY chats_messages.conversation_id " \
120
+ "ORDER BY chats_messages.created_at DESC, chats_messages.id DESC) AS human_rank"
121
+ ranked = Chats::Message.unscoped.human.where(conversation_id: ids)
122
+ .select(Arel.sql("chats_messages.id"), Arel.sql(ranking))
123
+ newest = Chats::Message.unscoped.from(ranked, :ranked_messages)
124
+ .where(Arel.sql("ranked_messages.human_rank = 1"))
125
+ .select(Arel.sql("ranked_messages.id"))
126
+ where(id: newest)
127
+ }
128
+
129
+ # The conversation ids +last_human_in+ ranks within: an Array for records
130
+ # and ids (a loaded relation included, read from memory), a subquery for
131
+ # an unloaded relation, as a derived table because MySQL rejects LIMIT
132
+ # directly inside IN, and a page is usually limited.
133
+ #
134
+ # A LIMITed or OFFSET page is its own query, word for word (its select
135
+ # list, DISTINCT and ORDER BY decide WHICH rows it holds, and PostgreSQL
136
+ # refuses a DISTINCT whose ORDER BY left the select list), so it must
137
+ # expose an `id` column, as the default select list does. Any other
138
+ # relation is just a set of ids: narrowed to `id`, unordered (the inbox's
139
+ # ORDER BY COALESCE(…) next to DISTINCT id is another refusal).
140
+ def self.conversation_ids_in(conversations) # :nodoc:
141
+ unless conversations.is_a?(ActiveRecord::Relation)
142
+ return Array(conversations).map { |c| c.is_a?(Chats::Conversation) ? c.id : c }
143
+ end
144
+ unless conversations.klass <= Chats::Conversation
145
+ raise ArgumentError, "expected Chats::Conversation records, ids or a relation of them, " \
146
+ "got a #{conversations.klass.name} relation"
147
+ end
148
+ return conversations.map(&:id) if conversations.loaded?
149
+
150
+ page = conversations.except(:includes, :preload, :eager_load)
151
+ page = page.reselect(page.arel_table[:id]).reorder(nil) unless page.limit_value || page.offset_value
152
+ Chats::Conversation.unscoped.from(page, :conversation_page).select(Arel.sql("conversation_page.id"))
153
+ end
154
+
84
155
  # Keyset (cursor) pagination for infinite scroll-up. OFFSET pagination
85
156
  # drifts when new messages arrive mid-scroll; anchoring on the oldest
86
157
  # loaded message can't skip or duplicate. `(created_at, id)` because
@@ -93,17 +164,35 @@ module Chats
93
164
  }
94
165
 
95
166
  # Catch-up scopes for ConversationsController#refresh (the stale-thread
96
- # recovery path — see the thread controller's refresh trigger). `since`
97
- # is the newest `updated_at` the client has rendered:
98
- # created_since — messages that arrived while the tab was asleep
99
- # (appended);
100
- # updated_since — messages the client HAS rendered that changed since
101
- # (edits, soft-delete tombstones — replaced in place).
102
- # `updated_since` excludes fresh rows so nothing renders twice — a Turbo
103
- # append of an existing dom_id would otherwise also MOVE that bubble to
104
- # the end (Turbo removes-then-appends on id collision).
167
+ # recovery path — see the thread controller's catch-up), on the client's
168
+ # two cursors. Each is a moment, to the microsecond, up to which the
169
+ # client KNOWS it has everything (a complete render, or a catch-up that
170
+ # succeeded — never a live bubble, which can arrive past one it missed),
171
+ # with the ids it has on screen from that moment on:
172
+ # created_after(at, seen:) — created at or after that moment and not
173
+ # on screen: appended;
174
+ # created_through(at, seen:) — the complement: what the client has;
175
+ # updated_after(at, seen:) — changed after that moment, or at it and
176
+ # not among the ones seen in that version.
177
+ # Replacements are created_through(after).updated_after(changed): changes
178
+ # to what is on screen, never a second copy of what is appended — a Turbo
179
+ # append of an existing dom_id would MOVE that bubble to the end. Two
180
+ # cursors, because one can't answer both: a message created at 10:15 and
181
+ # missed is older than a 10:20 edit that did arrive.
105
182
  # Pattern from Basecamp's Campfire (Rooms::RefreshesController):
106
183
  # https://github.com/basecamp/once-campfire
184
+ scope :created_after, lambda { |at, seen: []|
185
+ where(arel_table[:created_at].gteq(at)).where.not(id: seen)
186
+ }
187
+ scope :created_through, lambda { |at, seen: []|
188
+ where(arel_table[:created_at].lt(at)).or(where(id: seen))
189
+ }
190
+ scope :updated_after, lambda { |at, seen: []|
191
+ where(arel_table[:updated_at].gt(at)).or(where(updated_at: at).where.not(id: seen))
192
+ }
193
+ # The one-cursor catch-up of bubbles ejected before they carried the
194
+ # cursors above (`data-updated-at-ms` alone, rounded up): what was
195
+ # created after it, and what was rendered by then and changed since.
107
196
  scope :created_since, ->(time) { where("chats_messages.created_at > ?", time) }
108
197
  scope :updated_since, lambda { |time|
109
198
  where("chats_messages.updated_at > ?", time).where("chats_messages.created_at <= ?", time)
@@ -27,6 +27,15 @@ module Chats
27
27
  # Rails behavior: https://guides.rubyonrails.org/active_record_callbacks.html#using-both-after-create-commit-and-after-update-commit
28
28
  after_commit :broadcast_change, on: %i[create destroy]
29
29
 
30
+ # A reaction changes how its message renders, so it moves the message's
31
+ # updated_at: that is what a thread catching up on missed broadcasts asks
32
+ # for (Message.updated_after). update_columns, not touch: the
33
+ # broadcast above already re-renders the bubble, and touch would run the
34
+ # message's own update callbacks and broadcast it a second time. Not when
35
+ # the message itself is being destroyed and takes its reactions with it.
36
+ after_create :stamp_message
37
+ after_destroy :stamp_message, unless: :destroyed_by_association
38
+
30
39
  # Add the reaction if absent, remove it if present (the universal
31
40
  # tap-to-toggle semantic). Returns the created reaction, or false when
32
41
  # toggled off. Race-safe: a concurrent double-tap resolves through the
@@ -68,6 +77,10 @@ module Chats
68
77
  errors.add(:reactor, :not_a_participant) unless message.conversation.participant?(reactor)
69
78
  end
70
79
 
80
+ def stamp_message
81
+ message.update_columns(updated_at: Time.current)
82
+ end
83
+
71
84
  def broadcast_change
72
85
  Chats::Broadcasts.message_updated(message) unless message.destroyed?
73
86
  end