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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +349 -0
- data/README.md +91 -8
- data/app/assets/stylesheets/chats.css +114 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +305 -63
- data/app/helpers/chats/engine_helper.rb +27 -4
- data/app/javascript/chats/dock_controller.js +54 -4
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/split_controller.js +97 -0
- data/app/javascript/chats/thread_controller.js +245 -54
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +20 -4
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_thread.html.erb +29 -54
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/index.html.erb +91 -15
- data/app/views/chats/conversations/pane.html.erb +12 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +10 -65
- data/app/views/chats/dock/_dock.html.erb +14 -5
- data/app/views/chats/messages/_message.html.erb +8 -6
- data/chats-0.3.2.gem +0 -0
- data/chats-0.4.0.gem +0 -0
- data/chats-0.4.1.gem +0 -0
- data/chats-0.4.2.gem +0 -0
- data/chats-0.5.0.gem +0 -0
- data/chats-0.5.1.gem +0 -0
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/config/routes.rb +3 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/lib/chats/broadcasts.rb +102 -7
- data/lib/chats/configuration.rb +23 -0
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/conversation.rb +38 -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 +9 -0
- metadata +21 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
<%# The thread page
|
|
2
|
-
(chats/conversations/
|
|
3
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
#
|
|
37
|
-
|
|
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
|
|
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
|
-
<%#
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
data-
|
|
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"
|
data/config/locales/en.yml
CHANGED
data/config/locales/es.yml
CHANGED
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/chats/broadcasts.rb
CHANGED
|
@@ -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 —
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
data/lib/chats/configuration.rb
CHANGED
|
@@ -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)
|
data/lib/chats/models/message.rb
CHANGED
|
@@ -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
|
|
97
|
-
# is
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
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
|