chats 0.5.0 → 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 +231 -0
- data/README.md +59 -6
- data/app/assets/stylesheets/chats.css +3 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +204 -52
- data/app/helpers/chats/engine_helper.rb +27 -4
- data/app/javascript/chats/dock_controller.js +39 -3
- 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 +245 -54
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_thread.html.erb +7 -46
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/index.html.erb +22 -9
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/dock/_dock.html.erb +12 -4
- data/app/views/chats/messages/_message.html.erb +8 -6
- data/config/importmap.rb +6 -0
- data/config/routes.rb +3 -0
- data/lib/chats/broadcasts.rb +102 -7
- 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
- metadata +6 -1
|
@@ -67,52 +67,13 @@
|
|
|
67
67
|
|
|
68
68
|
<%= yield %>
|
|
69
69
|
|
|
70
|
-
<%# tabindex: the arrow keys scroll the history (WCAG 2.1.1).
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<%= chats_messager_avatar(counterpart) %>
|
|
78
|
-
<strong><%= Chats.display_name_for(counterpart) %></strong>
|
|
79
|
-
<% end %>
|
|
80
|
-
<%= t("chats.dock.hello", name: conversation.title_for(viewer)) %>
|
|
81
|
-
</div>
|
|
82
|
-
<% end %>
|
|
83
|
-
<% if more && messages.any? %>
|
|
84
|
-
<%# Lazy frame chain for infinite scroll-up: when this frame becomes
|
|
85
|
-
visible, Turbo fetches the previous keyset page (?before=oldest id),
|
|
86
|
-
whose response contains a frame with this SAME id — older messages
|
|
87
|
-
plus, recursively, the next frame. See ConversationsController#show. %>
|
|
88
|
-
<%= turbo_frame_tag "chats_page_#{messages.first.id}",
|
|
89
|
-
src: conversation_path(conversation, before: messages.first.id),
|
|
90
|
-
loading: :lazy do %>
|
|
91
|
-
<div class="chats-loader"><%= t("chats.thread.loading") %></div>
|
|
92
|
-
<% end %>
|
|
93
|
-
<% end %>
|
|
94
|
-
|
|
95
|
-
<div id="<%= dom_id(conversation, :messages) %>" class="chats-thread__messages">
|
|
96
|
-
<%# Explicit loop (not a collection render) so the «new messages»
|
|
97
|
-
divider can sit BEFORE the first unread bubble (WhatsApp's "the
|
|
98
|
-
catch-up starts here" line). Server-rendered on the initial page
|
|
99
|
-
only; it scrolls away naturally as the thread lives on. %>
|
|
100
|
-
<% messages.each do |message| %>
|
|
101
|
-
<% if message.id == first_unread_id %>
|
|
102
|
-
<div class="chats-thread__unread-line" role="separator">
|
|
103
|
-
<span><%= t("chats.thread.new_messages") %></span>
|
|
104
|
-
</div>
|
|
105
|
-
<% end %>
|
|
106
|
-
<%= render "chats/messages/message", message: message %>
|
|
107
|
-
<% end %>
|
|
108
|
-
</div>
|
|
109
|
-
|
|
110
|
-
<%= render "chats/conversations/read_state", conversation: conversation %>
|
|
111
|
-
|
|
112
|
-
<div id="<%= dom_id(conversation, :typing) %>"
|
|
113
|
-
class="chats-typing"
|
|
114
|
-
data-chats--thread-target="typing"
|
|
115
|
-
hidden></div>
|
|
70
|
+
<%# tabindex: the arrow keys scroll the history (WCAG 2.1.1). The id is
|
|
71
|
+
what the stale-thread catch-up re-renders when the thread fell more
|
|
72
|
+
than a page behind (ConversationsController#refresh). %>
|
|
73
|
+
<div id="<%= dom_id(conversation, :history) %>" class="chats-thread__scroll" data-chats--thread-target="scroller" tabindex="0">
|
|
74
|
+
<%= render "chats/conversations/history", conversation: conversation, messages: messages, more: more,
|
|
75
|
+
first_unread_id: first_unread_id, counterpart: counterpart,
|
|
76
|
+
window: window %>
|
|
116
77
|
</div>
|
|
117
78
|
|
|
118
79
|
<%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# Every unread badge on the page (ConversationsController#badge), as the
|
|
2
|
+
badge streams would have sent them: the nav badge
|
|
3
|
+
(Chats::Broadcasts.update_badge_of) and the dock's (chats/dock/_update).
|
|
4
|
+
A page without one of them simply has nothing to replace. %>
|
|
5
|
+
<%= turbo_stream.replace "chats_unread_badge", partial: "chats/shared/unread_badge", locals: { count: count } %>
|
|
6
|
+
<%= turbo_stream.replace_all ".chats-dock [data-chats-unread-badge]", partial: "chats/dock/badge", locals: { count: count } %>
|
|
@@ -10,18 +10,22 @@
|
|
|
10
10
|
The chats--refresh-inbox wrapper heals a MISSED refresh broadcast: Action
|
|
11
11
|
Cable has no replay, so a broadcast sent while the socket was down (tab
|
|
12
12
|
backgrounded, network blip) would leave the inbox stale until the user
|
|
13
|
-
navigated. The controller re-runs the same page refresh
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
navigated. The controller re-runs the same page refresh when the stream
|
|
14
|
+
reconnects, when the page comes back after 10 seconds hidden, when the
|
|
15
|
+
network comes back and when the browser restores the page (see
|
|
16
|
+
chats/recovery.js), and reruns a refresh Turbo dropped because it
|
|
17
|
+
arrived mid-visit. It wraps the <turbo-cable-stream-source> that
|
|
18
|
+
`turbo_stream_from` renders: the stream's own confirmations are how it
|
|
19
|
+
sees a reconnect. One per page: don't wrap it in a reconciler of your
|
|
20
|
+
own as well. %>
|
|
17
21
|
|
|
18
22
|
<%# The OTHER inbox-staleness case, distinct from a missed broadcast: a Turbo
|
|
19
23
|
RESTORATION visit (browser back / Hotwire Native stack pop) serves the
|
|
20
24
|
inbox's cached snapshot with no GET. Open a chat from a profile, send a
|
|
21
25
|
message, tap back — the snapshot cached before the chat existed comes back
|
|
22
26
|
and the new conversation is missing until you navigate away and return.
|
|
23
|
-
The
|
|
24
|
-
|
|
27
|
+
The wrapper above can't help (it fires on reconnect and visibility, not
|
|
28
|
+
on a restore visit). `turbo-cache-control: no-cache` makes the inbox
|
|
25
29
|
uncacheable, so a restore visit re-fetches from the network and is always
|
|
26
30
|
fresh — on the web AND in Hotwire Native, whose back-pop issues a Turbo
|
|
27
31
|
`.restore` visit that honors this. Requires the host layout to
|
|
@@ -49,15 +53,24 @@
|
|
|
49
53
|
<% end %>
|
|
50
54
|
|
|
51
55
|
<% if Chats.config.search && !@inbox.filtered? %>
|
|
52
|
-
|
|
56
|
+
<%# The inbox's own path exactly as it was opened (/messages or
|
|
57
|
+
/messages/: the engine answers both), so the URL a search puts in the
|
|
58
|
+
address bar is still this page to Turbo, and the next refresh morphs
|
|
59
|
+
it instead of re-rendering it from scratch. %>
|
|
60
|
+
<%= form_with url: request.original_fullpath.split("?").first,
|
|
53
61
|
method: :get,
|
|
54
62
|
class: "chats-search",
|
|
55
|
-
role
|
|
63
|
+
# `html:`: form_with drops attributes it doesn't know (role among them).
|
|
64
|
+
html: { role: "search" },
|
|
56
65
|
data: {
|
|
57
66
|
controller: "chats--debounced-submit",
|
|
58
67
|
action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
|
|
59
68
|
"chats--debounced-submit-delay-value": 250,
|
|
60
|
-
turbo_frame: "chats_inbox_results"
|
|
69
|
+
turbo_frame: "chats_inbox_results",
|
|
70
|
+
# The query goes in the URL (replaced, not pushed): a page
|
|
71
|
+
# refresh re-requests the URL, so a live update or a catch-up
|
|
72
|
+
# keeps the results and the field as they are.
|
|
73
|
+
turbo_action: "replace"
|
|
61
74
|
} do %>
|
|
62
75
|
<%= search_field_tag :q, params[:q],
|
|
63
76
|
placeholder: t("chats.inbox.search_placeholder"),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
<%# Stale-thread catch-up payload (ConversationsController#refresh):
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
<%# Stale-thread catch-up payload (ConversationsController#refresh): what the
|
|
2
|
+
stream would have delivered while the client wasn't listening. Appends
|
|
3
|
+
are idempotent by dom_id, so a message that raced in through the live
|
|
4
|
+
stream during the fetch can't duplicate. %>
|
|
5
5
|
<%= turbo_stream.append dom_id(@conversation, :messages) do %>
|
|
6
6
|
<%= render partial: "chats/messages/message", collection: @new_messages, as: :message %>
|
|
7
7
|
<% end if @new_messages.any? %>
|
|
@@ -11,3 +11,23 @@
|
|
|
11
11
|
partial: "chats/messages/message",
|
|
12
12
|
locals: { message: message } %>
|
|
13
13
|
<% end %>
|
|
14
|
+
|
|
15
|
+
<%# Who has read what: a read the stream missed would leave "Sent" where it
|
|
16
|
+
should say "Seen". %>
|
|
17
|
+
<%= turbo_stream.replace dom_id(@conversation, :read_state),
|
|
18
|
+
partial: "chats/conversations/read_state",
|
|
19
|
+
locals: { conversation: @conversation } %>
|
|
20
|
+
|
|
21
|
+
<%# `deletion: :hard` only: every message from the client's oldest bubble on
|
|
22
|
+
that still exists (the chats_prune action, registered by the thread
|
|
23
|
+
controller, removes the rest). %>
|
|
24
|
+
<%# Where the client stands now (ConversationsController#caught_up_cursors):
|
|
25
|
+
its cursors move only when an answer like this one arrives. %>
|
|
26
|
+
<%= @caught_up %>
|
|
27
|
+
|
|
28
|
+
<% if @present %>
|
|
29
|
+
<%= turbo_stream_action_tag :chats_prune,
|
|
30
|
+
target: dom_id(@conversation, :history),
|
|
31
|
+
"data-ids": @present[:ids].join(" "),
|
|
32
|
+
"data-until": @present[:until] %>
|
|
33
|
+
<% 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 " \
|
|
@@ -88,7 +91,8 @@
|
|
|
88
91
|
<%= form_with url: chats_routes.conversations_path,
|
|
89
92
|
method: :get,
|
|
90
93
|
class: "chats-search chats-dock__search",
|
|
91
|
-
role
|
|
94
|
+
# `html:`: form_with drops attributes it doesn't know (role among them).
|
|
95
|
+
html: { role: "search" },
|
|
92
96
|
data: {
|
|
93
97
|
controller: "chats--debounced-submit",
|
|
94
98
|
action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
|
|
@@ -106,10 +110,14 @@
|
|
|
106
110
|
<% end %>
|
|
107
111
|
|
|
108
112
|
<%# Lazy: nothing is fetched until the panel first opens; until then,
|
|
109
|
-
four still rows. The controller reloads it when the signal says
|
|
113
|
+
four still rows. The controller reloads it when the signal says,
|
|
114
|
+
and when its stream may have missed a signal. `refresh: :morph`:
|
|
115
|
+
a reload morphs the rows in place, so the list keeps its scroll
|
|
116
|
+
position, and it reloads its own src, so a search stays. %>
|
|
110
117
|
<%= turbo_frame_tag "chats_dock_list",
|
|
111
118
|
src: chats_routes.dock_conversations_path(recent: recent),
|
|
112
119
|
loading: :lazy,
|
|
120
|
+
refresh: :morph,
|
|
113
121
|
target: "_top",
|
|
114
122
|
class: "chats-dock__results",
|
|
115
123
|
# 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/config/importmap.rb
CHANGED
|
@@ -16,3 +16,9 @@ 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
|
+
|
|
21
|
+
# Not a controller: what the inbox, thread, badge and dock controllers share
|
|
22
|
+
# to catch up on broadcasts they missed. Outside controllers/ so the eager
|
|
23
|
+
# loader never tries to register it.
|
|
24
|
+
pin "chats/recovery", to: "chats/recovery.js"
|
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)
|
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.
|
|
@@ -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
|
data/lib/chats/version.rb
CHANGED