chats 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +478 -0
- data/README.md +150 -10
- data/app/assets/stylesheets/chats.css +599 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +273 -55
- data/app/helpers/chats/engine_helper.rb +102 -6
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1106 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/thread_controller.js +317 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
- data/app/views/chats/conversations/_group.html.erb +6 -2
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +133 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +24 -10
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +11 -118
- data/app/views/chats/conversations/window.html.erb +26 -0
- data/app/views/chats/dock/_badge.html.erb +12 -0
- data/app/views/chats/dock/_dock.html.erb +138 -0
- data/app/views/chats/dock/_signal.html.erb +26 -0
- data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
- data/app/views/chats/dock/_window.html.erb +23 -0
- data/app/views/chats/dock/_window_header.html.erb +75 -0
- data/app/views/chats/dock/_window_shell.html.erb +17 -0
- data/app/views/chats/messages/_composer.html.erb +11 -1
- data/app/views/chats/messages/_message.html.erb +8 -6
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +17 -0
- data/config/locales/es.yml +17 -0
- data/config/routes.rb +8 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/gemfiles/rails_7.1.gemfile +2 -0
- data/gemfiles/rails_7.2.gemfile +2 -0
- data/gemfiles/rails_8.1.gemfile +2 -0
- data/lib/chats/broadcasts.rb +178 -17
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +57 -0
- data/lib/chats/models/message.rb +98 -9
- data/lib/chats/models/reaction.rb +13 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +23 -2
|
@@ -2,8 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module Chats
|
|
4
4
|
# The inbox (index), the thread (show), starting conversations from host
|
|
5
|
-
# pages (create),
|
|
5
|
+
# pages (create), the messaging dock's recent list (dock), and the
|
|
6
|
+
# per-member actions (read/typing/leave/mute).
|
|
6
7
|
class ConversationsController < ApplicationController
|
|
8
|
+
# The most rows the dock's recent list shows, whatever `recent:` asks.
|
|
9
|
+
DOCK_RECENT_LIMIT = 50
|
|
10
|
+
# How far before its list of what exists a catch-up stops naming
|
|
11
|
+
# deletions (see #present_messages).
|
|
12
|
+
PRESENT_MARGIN = 10.seconds
|
|
13
|
+
# The most ids a catch-up cursor may name, and the longest id (a UUID is
|
|
14
|
+
# 36): a client past either gets its history re-rendered instead.
|
|
15
|
+
CURSOR_IDS_LIMIT = 200
|
|
16
|
+
CURSOR_ID_LENGTH = 64
|
|
17
|
+
|
|
7
18
|
before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
|
|
8
19
|
helper_method :chats_counterpart
|
|
9
20
|
|
|
@@ -13,6 +24,11 @@ module Chats
|
|
|
13
24
|
# Rows are Conversation | InboxGroup: stacking, search and the `?with=`
|
|
14
25
|
# filter all live in Chats::Inbox, so this action stays three lines.
|
|
15
26
|
def index
|
|
27
|
+
# The dock's search form posts here, so that without JavaScript it
|
|
28
|
+
# lands on the full inbox's results; from the dock's frame, the answer
|
|
29
|
+
# is the dock's own list.
|
|
30
|
+
return dock if turbo_frame_request_id == "chats_dock_list"
|
|
31
|
+
|
|
16
32
|
@inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
|
|
17
33
|
@rows = @inbox.rows
|
|
18
34
|
@unread_counts = @inbox.unread_counts
|
|
@@ -23,22 +39,41 @@ module Chats
|
|
|
23
39
|
@conversations = @inbox.conversations
|
|
24
40
|
end
|
|
25
41
|
|
|
42
|
+
# The messaging dock's recent list (`chats_dock`): the first rows of the
|
|
43
|
+
# viewer's inbox — the same rows, stacks and search, through the same
|
|
44
|
+
# Chats::Inbox — rendered into the dock's `chats_dock_list` frame. The
|
|
45
|
+
# frame asks for `?recent=` rows (the helper's `recent:` option).
|
|
46
|
+
def dock
|
|
47
|
+
@inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], limit: dock_recent)
|
|
48
|
+
@rows = @inbox.rows
|
|
49
|
+
@unread_counts = @inbox.unread_counts
|
|
50
|
+
|
|
51
|
+
render :dock, layout: false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The viewer's unread badges as a Turbo Stream: the nav badge
|
|
55
|
+
# (`chats_unread_badge`) and every badge in the dock, replaced with the
|
|
56
|
+
# same count the badge streams send. What a page asks for when it may
|
|
57
|
+
# have missed them (chats/recovery: a reconnect, a return to a hidden
|
|
58
|
+
# page, a restored page). Reads nothing, writes nothing.
|
|
59
|
+
def badge
|
|
60
|
+
render :badge, formats: :turbo_stream, locals: { count: chats_current_messager.unread_chats_count }
|
|
61
|
+
end
|
|
62
|
+
|
|
26
63
|
# The thread. Renders the LATEST page of messages; older pages stream in
|
|
27
64
|
# through a lazy Turbo Frame chain (keyset-paginated — see
|
|
28
65
|
# Message.before_message and _messages_page.html.erb).
|
|
66
|
+
#
|
|
67
|
+
# `?window=1` from a Turbo frame is the same thread as a floating dock
|
|
68
|
+
# window: the window template (its compact header around the same
|
|
69
|
+
# thread body), no layout.
|
|
29
70
|
def show
|
|
30
71
|
@participant = @conversation.participant_for(chats_current_messager)
|
|
31
72
|
|
|
32
73
|
anchor = params[:before].present? ? @conversation.messages.find_by(id: params[:before]) : nil
|
|
33
|
-
scope =
|
|
34
|
-
scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
|
|
74
|
+
scope = rendered_messages
|
|
35
75
|
scope = scope.before_message(anchor) if anchor
|
|
36
|
-
|
|
37
|
-
# Fetch newest-first + reverse so "the last N messages" render in
|
|
38
|
-
# chronological order. One extra record peeks whether older pages exist.
|
|
39
|
-
page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
|
|
40
|
-
@more_messages = page.size > Chats.config.messages_per_page
|
|
41
|
-
@messages = page.first(Chats.config.messages_per_page).reverse
|
|
76
|
+
@messages, @more_messages = latest_page_of(scope)
|
|
42
77
|
|
|
43
78
|
if anchor
|
|
44
79
|
# Older-page request from the pagination frame: render just the page.
|
|
@@ -46,58 +81,69 @@ module Chats
|
|
|
46
81
|
locals: { conversation: @conversation, messages: @messages, more: @more_messages }
|
|
47
82
|
else
|
|
48
83
|
# The «new messages» divider: computed BEFORE read! advances the
|
|
49
|
-
# horizon (after it, nothing is unread anymore).
|
|
50
|
-
|
|
51
|
-
# deeper than one page it pins to the top of the page instead —
|
|
52
|
-
# the scroll-up frame chain holds the rest.
|
|
53
|
-
if @participant&.unread?
|
|
54
|
-
@first_unread_id = @participant.unread_messages.where(id: @messages.map(&:id)).oldest_first.pick(:id) ||
|
|
55
|
-
@messages.first&.id
|
|
56
|
-
end
|
|
84
|
+
# horizon (after it, nothing is unread anymore).
|
|
85
|
+
@first_unread_id = first_unread_id(@participant, @messages)
|
|
57
86
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
87
|
+
if dock_window?
|
|
88
|
+
# A window does NOT read on render: it may be restored minimized
|
|
89
|
+
# after a reload, and a header nobody has looked at must not
|
|
90
|
+
# swallow the unread badge. The thread controller posts #read once
|
|
91
|
+
# the window is actually on screen (`read-pending`).
|
|
92
|
+
render :window, layout: false
|
|
93
|
+
elsif !prefetch?
|
|
94
|
+
# Opening the thread reads it. (Live appends while the thread
|
|
95
|
+
# stays open are read via the thread controller's POST to #read.)
|
|
96
|
+
@participant&.read!
|
|
97
|
+
end
|
|
61
98
|
end
|
|
62
99
|
end
|
|
63
100
|
|
|
64
|
-
# Stale-thread catch-up
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
101
|
+
# Stale-thread catch-up, on the client's cursors (see Message's catch-up
|
|
102
|
+
# scopes): `after=` µs and `after_ids=`, its newest creation and the ids
|
|
103
|
+
# it has at that moment (0 and none for an empty thread); `changed=` µs
|
|
104
|
+
# and `changed_ids=`, its newest change; `from=` µs, its oldest bubble's
|
|
105
|
+
# creation. Bubbles ejected before they carried these send `since=` ms
|
|
106
|
+
# alone, and are answered as they always were. The thread
|
|
107
|
+
# controller calls this whenever chats/recovery says the stream may have
|
|
108
|
+
# missed something: a reconnect, a return after 10 seconds hidden, the
|
|
109
|
+
# network back, a restored page. Mobile WebViews suspend WebSockets
|
|
110
|
+
# aggressively, so without this a backgrounded chat silently loses
|
|
111
|
+
# messages until a manual reload. Pattern from Basecamp's Campfire
|
|
112
|
+
# (Rooms::RefreshesController): https://github.com/basecamp/once-campfire
|
|
113
|
+
#
|
|
114
|
+
# The answer touches the history only, never the composer or its draft:
|
|
115
|
+
# messages created since are appended, ones changed since (edits,
|
|
116
|
+
# tombstones, reactions) replaced, the read state replaced, and — under
|
|
117
|
+
# `deletion: :hard` — the bubbles that no longer exist removed. Reads
|
|
118
|
+
# nothing: catching up is not reading (the controller posts #read once
|
|
119
|
+
# the viewer can see the new messages).
|
|
120
|
+
#
|
|
121
|
+
# `render turbo_stream:`, NOT `render html: … content_type:` — the
|
|
122
|
+
# latter forces text/html and silently ignores the content type, so the
|
|
123
|
+
# body says <turbo-stream> while the response says it isn't one.
|
|
73
124
|
def refresh
|
|
74
|
-
head :no_content and return if params[:since].blank?
|
|
125
|
+
head :no_content and return if params[:changed].blank? && params[:since].blank?
|
|
75
126
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# (morph + scroll preservation) re-renders the latest page + frame
|
|
85
|
-
# chain correctly instead.
|
|
86
|
-
#
|
|
87
|
-
# `render turbo_stream:`, NOT `render html: … content_type:` — the
|
|
88
|
-
# latter forces text/html and silently ignores the content type, so the
|
|
89
|
-
# body says <turbo-stream> while the response says it isn't one.
|
|
90
|
-
#
|
|
91
|
-
# `request_id: nil` on purpose: Turbo skips a refresh tagged with a
|
|
92
|
-
# request id it recognizes as its own, and this response is the answer
|
|
93
|
-
# to the client's OWN catch-up fetch — the one client that must not
|
|
94
|
-
# skip it.
|
|
95
|
-
if @new_messages.size > Chats.config.messages_per_page
|
|
96
|
-
render turbo_stream: turbo_stream.refresh(request_id: nil)
|
|
97
|
-
return
|
|
98
|
-
end
|
|
127
|
+
# A client whose cursors name more than the limit (a busy thread whose
|
|
128
|
+
# live bubbles piled up since it last caught up, or a forged request)
|
|
129
|
+
# is answered with what is cheap at any size: the latest page.
|
|
130
|
+
return render_from_scratch if cursors_over_limit?
|
|
131
|
+
|
|
132
|
+
scope = rendered_messages
|
|
133
|
+
new_messages, @updated_messages = catch_up_on(scope)
|
|
134
|
+
@new_messages = new_messages.oldest_first.limit(Chats.config.messages_per_page + 1).to_a
|
|
99
135
|
|
|
100
|
-
|
|
136
|
+
# More than a page behind: splicing an arbitrary backlog through
|
|
137
|
+
# surgical appends would leave a hole in the scroll-up chain. Re-render
|
|
138
|
+
# the history at the latest page instead, exactly as opening the thread
|
|
139
|
+
# does, frame chain included. A thread view ejected before the history
|
|
140
|
+
# had its id (no `history=1`) gets the page refresh it always got;
|
|
141
|
+
# `request_id: nil`, because Turbo skips a refresh tagged with a request
|
|
142
|
+
# of its own, and this one answers the client's own catch-up.
|
|
143
|
+
return render_from_scratch if @new_messages.size > Chats.config.messages_per_page
|
|
144
|
+
|
|
145
|
+
@caught_up = caught_up_cursors if params[:changed].present?
|
|
146
|
+
@present = present_messages if Chats.config.deletion == :hard && params[:from].present?
|
|
101
147
|
render "chats/conversations/refresh", formats: :turbo_stream
|
|
102
148
|
end
|
|
103
149
|
|
|
@@ -106,6 +152,12 @@ module Chats
|
|
|
106
152
|
# `chat_button_to` helper — unforgeable and purpose-scoped, so raw
|
|
107
153
|
# polymorphic params never reach `GlobalID::Locator`. Policy and block
|
|
108
154
|
# checks still run inside `chat_with` (defense in depth).
|
|
155
|
+
#
|
|
156
|
+
# `dock=1` (`chat_button_to …, dock: true`) answers a Turbo Stream that
|
|
157
|
+
# appends the conversation's floating window to the dock instead of
|
|
158
|
+
# navigating. Same id, so a window already open is replaced, not
|
|
159
|
+
# doubled; on a page without the dock the stream has no target and does
|
|
160
|
+
# nothing. Without Turbo it is the plain redirect.
|
|
109
161
|
def create
|
|
110
162
|
recipient = locate_signed!(params.require(:recipient_sgid), purpose: :chats_recipient)
|
|
111
163
|
raise ActiveRecord::RecordNotFound unless Chats.messager_class?(recipient.class)
|
|
@@ -114,7 +166,16 @@ module Chats
|
|
|
114
166
|
locate_signed!(params[:subject_sgid], purpose: :chats_subject)
|
|
115
167
|
|
|
116
168
|
conversation = chats_current_messager.chat_with(recipient, about: subject)
|
|
117
|
-
|
|
169
|
+
|
|
170
|
+
if params[:dock].present? && request.format.turbo_stream?
|
|
171
|
+
render turbo_stream: turbo_stream.append(
|
|
172
|
+
"chats_dock_windows",
|
|
173
|
+
partial: "chats/dock/window",
|
|
174
|
+
locals: { conversation: conversation, focus: true }
|
|
175
|
+
)
|
|
176
|
+
else
|
|
177
|
+
redirect_to conversation_path(conversation)
|
|
178
|
+
end
|
|
118
179
|
rescue Chats::BlockedError
|
|
119
180
|
# Fallback is the INBOX (engine root) — inside the engine, `root_path`
|
|
120
181
|
# already resolves there, never to the host root.
|
|
@@ -165,6 +226,163 @@ module Chats
|
|
|
165
226
|
@conversation = find_conversation
|
|
166
227
|
end
|
|
167
228
|
|
|
229
|
+
def time_from_ms(value)
|
|
230
|
+
Time.zone.at(0, value.to_i, :millisecond)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def time_from_us(value)
|
|
234
|
+
Time.zone.at(0, value.to_i, :microsecond)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# What to append and what to replace: on the two microsecond cursors,
|
|
238
|
+
# or on the one millisecond cursor of bubbles ejected before them.
|
|
239
|
+
def catch_up_on(scope)
|
|
240
|
+
if params[:changed].present?
|
|
241
|
+
created_at, created_seen = cursor(:after)
|
|
242
|
+
updated_at, updated_seen = cursor(:changed)
|
|
243
|
+
[scope.created_after(created_at, seen: created_seen),
|
|
244
|
+
scope.created_through(created_at, seen: created_seen).updated_after(updated_at, seen: updated_seen)]
|
|
245
|
+
else
|
|
246
|
+
since = time_from_ms(params[:since])
|
|
247
|
+
[scope.created_since(since), scope.updated_since(since)]
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# The moment and the ids seen with it, from `<name>=µs` and `<name>_ids=1,2`.
|
|
252
|
+
def cursor(name)
|
|
253
|
+
[time_from_us(params[name]), params[:"#{name}_ids"].to_s.split(",")]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def cursors_over_limit?
|
|
257
|
+
return true if params[:full].present?
|
|
258
|
+
|
|
259
|
+
%i[after_ids changed_ids].any? do |name|
|
|
260
|
+
ids = params[name].to_s.split(",")
|
|
261
|
+
ids.size > CURSOR_IDS_LIMIT || ids.any? { |id| id.size > CURSOR_ID_LENGTH }
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Where the client stands once it has rendered this answer: what it was
|
|
266
|
+
# sent is now known to be on screen, so the cursors move to it. Sent
|
|
267
|
+
# back with the answer (the chats_caught_up action), because only an
|
|
268
|
+
# answer that arrived may move them.
|
|
269
|
+
def caught_up_cursors
|
|
270
|
+
after = cursor(:after).first
|
|
271
|
+
changed_at, changed_seen = cursor(:changed)
|
|
272
|
+
sent = @new_messages + @updated_messages.to_a
|
|
273
|
+
after = [after, *@new_messages.map(&:created_at)].max
|
|
274
|
+
|
|
275
|
+
newest = sent.map(&:updated_at).max
|
|
276
|
+
changed_seen = [] if newest && newest > changed_at
|
|
277
|
+
changed_at = [changed_at, newest].compact.max
|
|
278
|
+
changed_seen |= sent.select { |message| message.updated_at == changed_at }.map { |message| message.id.to_s }
|
|
279
|
+
|
|
280
|
+
cursors_tag(after, changed_at, changed_seen)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def cursors_tag(after, changed_at, changed_seen)
|
|
284
|
+
helpers.turbo_stream_action_tag(
|
|
285
|
+
:chats_caught_up,
|
|
286
|
+
target: helpers.dom_id(@conversation, :messages),
|
|
287
|
+
"data-after": microseconds(after), "data-changed": microseconds(changed_at),
|
|
288
|
+
"data-changed-ids": changed_seen.join(","),
|
|
289
|
+
# Which render of the thread asked: an answer for an earlier one is
|
|
290
|
+
# ignored (see the thread controller's caughtUp).
|
|
291
|
+
"data-render": params[:render].to_s.first(CURSOR_ID_LENGTH).presence
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def microseconds(time)
|
|
296
|
+
(time.to_i * 1_000_000) + time.usec
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The history at its latest page, or, for a thread view ejected before
|
|
300
|
+
# the history had its id (no `history=1`), the page refresh it always
|
|
301
|
+
# got. `request_id: nil`: Turbo skips a refresh tagged with a request of
|
|
302
|
+
# its own, and this one answers the client's own catch-up.
|
|
303
|
+
def render_from_scratch
|
|
304
|
+
return render_latest_history if params[:history].present?
|
|
305
|
+
|
|
306
|
+
render turbo_stream: turbo_stream.refresh(request_id: nil)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The conversation's messages with everything a bubble renders.
|
|
310
|
+
def rendered_messages
|
|
311
|
+
scope = @conversation.messages.includes(:sender, :reactions)
|
|
312
|
+
scope.respond_to?(:with_attached_files) ? scope.with_attached_files : scope
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The newest page of +scope+, oldest first, and whether older pages
|
|
316
|
+
# exist. Fetched newest-first and reversed; one extra record peeks.
|
|
317
|
+
def latest_page_of(scope)
|
|
318
|
+
page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
|
|
319
|
+
[page.first(Chats.config.messages_per_page).reverse, page.size > Chats.config.messages_per_page]
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Where the «new messages» line goes: the oldest unread bubble on the
|
|
323
|
+
# page; when the backlog runs deeper than one page, the top of the page
|
|
324
|
+
# (the scroll-up frame chain holds the rest). Nil when nothing is unread.
|
|
325
|
+
def first_unread_id(participant, messages)
|
|
326
|
+
return unless participant&.unread?
|
|
327
|
+
|
|
328
|
+
participant.unread_messages.where(id: messages.map(&:id)).oldest_first.pick(:id) || messages.first&.id
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# The thread's history at its latest page, for a catch-up more than a
|
|
332
|
+
# page behind: the same messages, frame chain and «new messages» line
|
|
333
|
+
# the thread opens with (chats/conversations/_history).
|
|
334
|
+
#
|
|
335
|
+
# A complete render, so it carries its own cursors: the newest creation
|
|
336
|
+
# and the newest change on the page.
|
|
337
|
+
def render_latest_history
|
|
338
|
+
messages, more = latest_page_of(rendered_messages)
|
|
339
|
+
participant = @conversation.participant_for(chats_current_messager)
|
|
340
|
+
newest_change = messages.map(&:updated_at).max || Time.zone.at(0)
|
|
341
|
+
history = turbo_stream.update(
|
|
342
|
+
helpers.dom_id(@conversation, :history),
|
|
343
|
+
partial: "chats/conversations/history",
|
|
344
|
+
locals: { conversation: @conversation, messages: messages, more: more,
|
|
345
|
+
first_unread_id: first_unread_id(participant, messages),
|
|
346
|
+
counterpart: chats_counterpart, window: false }
|
|
347
|
+
)
|
|
348
|
+
cursors = cursors_tag(messages.map(&:created_at).max || Time.zone.at(0), newest_change,
|
|
349
|
+
messages.select { |message| message.updated_at == newest_change }.map { |m| m.id.to_s })
|
|
350
|
+
|
|
351
|
+
render turbo_stream: helpers.safe_join([history, cursors])
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Under `deletion: :hard` a deleted message leaves nothing to replace:
|
|
355
|
+
# the catch-up names every message from the client's oldest bubble on
|
|
356
|
+
# that still exists, and the client removes the bubbles not named.
|
|
357
|
+
# `until` sits PRESENT_MARGIN before the list is read: a message whose
|
|
358
|
+
# insert had not committed yet is missing from the list but may already
|
|
359
|
+
# have reached the client live — it must not be taken for a deleted one.
|
|
360
|
+
def present_messages
|
|
361
|
+
margin = Time.current - PRESENT_MARGIN
|
|
362
|
+
ids = @conversation.messages.where(created_at: time_from_us(params[:from])..).pluck(:id)
|
|
363
|
+
|
|
364
|
+
{ ids: ids.map { |id| helpers.dom_id(Chats::Message.new(id: id)) },
|
|
365
|
+
until: (margin.to_i * 1_000_000) + margin.usec }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# A dock window asks for its thread from inside its own turbo frame. A
|
|
369
|
+
# plain visit to the same URL (no frame) still gets the page.
|
|
370
|
+
def dock_window?
|
|
371
|
+
params[:window].present? && turbo_frame_request?
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Turbo 8 prefetches a link when the pointer rests on it, and a pointer
|
|
375
|
+
# resting on an inbox row is not somebody reading the thread. The visit
|
|
376
|
+
# itself, if it happens, reads.
|
|
377
|
+
# https://turbo.hotwired.dev/handbook/drive#prefetching-links-on-hover
|
|
378
|
+
def prefetch?
|
|
379
|
+
request.headers["X-Sec-Purpose"] == "prefetch" || request.headers["Sec-Purpose"]&.include?("prefetch")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def dock_recent
|
|
383
|
+
Integer(params[:recent], exception: false)&.clamp(1, DOCK_RECENT_LIMIT) || 10
|
|
384
|
+
end
|
|
385
|
+
|
|
168
386
|
def locate_signed!(sgid, purpose:)
|
|
169
387
|
GlobalID::Locator.locate_signed(sgid, for: purpose) || raise(ActiveRecord::RecordNotFound)
|
|
170
388
|
end
|
|
@@ -15,6 +15,7 @@ module Chats
|
|
|
15
15
|
composer_actions
|
|
16
16
|
locked_composer
|
|
17
17
|
message_meta
|
|
18
|
+
dock_panel_footer
|
|
18
19
|
].freeze
|
|
19
20
|
|
|
20
21
|
# The "message this person" affordance for host pages — a listing, a
|
|
@@ -24,18 +25,24 @@ module Chats
|
|
|
24
25
|
#
|
|
25
26
|
# <%= chat_button_to @driver, about: @ride, label: "Chat with driver" %>
|
|
26
27
|
#
|
|
28
|
+
# `dock: true` opens the conversation in a floating window of the
|
|
29
|
+
# messaging dock (`chats_dock`) instead of navigating to it. It belongs
|
|
30
|
+
# with `chats_dock` on the same page: without the dock the button's Turbo
|
|
31
|
+
# Stream has nowhere to land and nothing happens.
|
|
32
|
+
#
|
|
27
33
|
# The recipient/subject travel as SIGNED GlobalIDs (purpose-scoped,
|
|
28
34
|
# minted here, verified in Chats::ConversationsController#create), so the
|
|
29
35
|
# endpoint never trusts raw polymorphic params. `expires_in: nil` because
|
|
30
36
|
# these buttons sit on long-lived pages — the default 1-month sgid expiry
|
|
31
37
|
# would quietly break stale tabs.
|
|
32
|
-
def chat_button_to(other, about: nil, label: nil, **html_options)
|
|
38
|
+
def chat_button_to(other, about: nil, label: nil, dock: false, **html_options)
|
|
33
39
|
viewer = chats_viewer
|
|
34
40
|
return if viewer.nil? || other.nil? || viewer == other
|
|
35
41
|
return unless Chats.can_message?(viewer, other)
|
|
36
42
|
|
|
37
43
|
params = { recipient_sgid: other.to_sgid(expires_in: nil, for: :chats_recipient).to_s }
|
|
38
44
|
params[:subject_sgid] = about.to_sgid(expires_in: nil, for: :chats_subject).to_s if about
|
|
45
|
+
params[:dock] = 1 if dock
|
|
39
46
|
|
|
40
47
|
button_to(
|
|
41
48
|
label || I18n.t("chats.buttons.chat"),
|
|
@@ -50,13 +57,97 @@ module Chats
|
|
|
50
57
|
# tab docks). Subscribes to the messager's badge stream so it updates in
|
|
51
58
|
# real time without refreshing the page it sits on (see
|
|
52
59
|
# Chats::Broadcasts for why badges get their own stream).
|
|
60
|
+
#
|
|
61
|
+
# The viewer's own badge also catches up on updates its stream missed
|
|
62
|
+
# (the chats--unread-badge controller asks GET /badge on a reconnect, on
|
|
63
|
+
# a return to a hidden page, on a restored page). Its wrapper is
|
|
64
|
+
# `display: contents` in chats.css, so it changes no layout.
|
|
53
65
|
def chats_unread_badge(messager = chats_viewer)
|
|
54
66
|
return if messager.nil?
|
|
55
67
|
|
|
56
|
-
safe_join([
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
badge = safe_join([
|
|
69
|
+
turbo_stream_from(messager, :chats_badge),
|
|
70
|
+
render(partial: "chats/shared/unread_badge", locals: { count: messager.unread_chats_count })
|
|
71
|
+
])
|
|
72
|
+
return badge unless messager == chats_viewer
|
|
73
|
+
|
|
74
|
+
tag.span(badge, class: "chats-unread-badge-live",
|
|
75
|
+
data: { controller: "chats--unread-badge",
|
|
76
|
+
"chats--unread-badge-url-value": chats_routes.badge_conversations_path })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The messaging dock, LinkedIn / Steam style: a bar in the bottom-right
|
|
80
|
+
# corner with the unread badge, a panel of recent conversations, and
|
|
81
|
+
# floating conversation windows. One line in the host's layout:
|
|
82
|
+
#
|
|
83
|
+
# <%= chats_dock %>
|
|
84
|
+
#
|
|
85
|
+
# Renders nothing for a signed-out visitor, and nothing on the engine's
|
|
86
|
+
# own pages (the inbox, a thread): those already ARE the messaging UI,
|
|
87
|
+
# and a window there would duplicate the thread's DOM ids the live
|
|
88
|
+
# streams address. The dock is permanent across Turbo visits, so the
|
|
89
|
+
# server can't take it away on the next page: it also hides ITSELF on
|
|
90
|
+
# any page carrying `data-chats-dock-hide` (the engine's pages do; put
|
|
91
|
+
# it on your own messaging pages). `everywhere: true` renders it on the
|
|
92
|
+
# engine's pages and ignores data-chats-dock-hide (it still never opens
|
|
93
|
+
# a window for a conversation the page itself shows).
|
|
94
|
+
#
|
|
95
|
+
# windows: how many windows may be open at once (3, and never more
|
|
96
|
+
# than fit beside the panel: 2 at 1280px, 1 at 768px)
|
|
97
|
+
# recent: how many conversations the panel lists (10)
|
|
98
|
+
# launcher: false drops the phone's floating button, for a host that
|
|
99
|
+
# opens the sheet from its own nav (see chats_dock_toggle)
|
|
100
|
+
def chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false)
|
|
101
|
+
viewer = chats_viewer
|
|
102
|
+
return if viewer.nil?
|
|
103
|
+
return if chats_engine_page? && !everywhere
|
|
104
|
+
|
|
105
|
+
render(partial: "chats/dock/dock",
|
|
106
|
+
locals: { viewer: viewer, windows: windows, recent: recent, launcher: launcher, everywhere: everywhere })
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The data that makes ANY link to a conversation — a recent-list row, a
|
|
110
|
+
# host's own link — open that conversation's dock window instead of
|
|
111
|
+
# navigating, when a dock is on the page. The link stays a real link: a
|
|
112
|
+
# modified click, a page without the dock, or no JavaScript navigates.
|
|
113
|
+
#
|
|
114
|
+
# <%= link_to "Chat", chats.conversation_path(c), data: chats_dock_opener(c) %>
|
|
115
|
+
def chats_dock_opener(conversation)
|
|
116
|
+
{ chats_dock_conversation_id: conversation.id }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The data that makes a host's own Messages link open the dock's panel
|
|
120
|
+
# (the full-screen sheet on a phone) instead of navigating, when a dock
|
|
121
|
+
# is on the page. Pair it with `chats_dock(launcher: false)` to replace
|
|
122
|
+
# the phone's floating button with your nav's. Without the dock, or
|
|
123
|
+
# without JavaScript, the link goes where its href says.
|
|
124
|
+
#
|
|
125
|
+
# <%= link_to "Messages", chats.conversations_path, data: chats_dock_toggle %>
|
|
126
|
+
def chats_dock_toggle
|
|
127
|
+
{ chats_dock_toggle: true }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The CLDR plural categories JavaScript's Intl.PluralRules answers with.
|
|
131
|
+
PLURAL_CATEGORIES = %i[zero one two few many other].freeze
|
|
132
|
+
|
|
133
|
+
# Every plural form of +key+ the current locale provides, for client-side
|
|
134
|
+
# pluralization ({ one: "…", few: "…", other: "…" }). The gem ships
|
|
135
|
+
# one/other; a host adds the forms its languages need (Polish few/many,
|
|
136
|
+
# Arabic zero/two…) in its own locale files. Empty when the key is
|
|
137
|
+
# missing, never a "translation missing" string.
|
|
138
|
+
def chats_plural_forms(key)
|
|
139
|
+
forms = I18n.t(key, default: {})
|
|
140
|
+
forms.is_a?(Hash) ? forms.slice(*PLURAL_CATEGORIES) : {}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Whether +viewer+ muted +conversation+, read from the preloaded seats
|
|
144
|
+
# (the inbox loads them), so a row costs no query for it.
|
|
145
|
+
def chats_muted?(conversation, viewer)
|
|
146
|
+
return false if viewer.nil?
|
|
147
|
+
|
|
148
|
+
conversation.participants.any? do |seat|
|
|
149
|
+
seat.muted? && seat.messager_type == viewer.class.polymorphic_name && seat.messager_id == viewer.id
|
|
150
|
+
end
|
|
60
151
|
end
|
|
61
152
|
|
|
62
153
|
# An avatar for any messager: whatever `config.messager_avatar` returns
|
|
@@ -148,7 +239,7 @@ module Chats
|
|
|
148
239
|
# app/views/chats/slots/_inbox_top.html.erb
|
|
149
240
|
#
|
|
150
241
|
# The slots: inbox_top, inbox_empty, conversation_header_actions,
|
|
151
|
-
# composer_actions, locked_composer, message_meta.
|
|
242
|
+
# composer_actions, locked_composer, message_meta, dock_panel_footer.
|
|
152
243
|
def chats_slot(name, **locals)
|
|
153
244
|
return unless chats_slot?(name)
|
|
154
245
|
|
|
@@ -275,6 +366,11 @@ module Chats
|
|
|
275
366
|
respond_to?(method_name) ? send(method_name) : nil
|
|
276
367
|
end
|
|
277
368
|
|
|
369
|
+
# Whether this view is one of the engine's own screens.
|
|
370
|
+
def chats_engine_page?
|
|
371
|
+
respond_to?(:controller) && controller.is_a?(Chats::ApplicationController)
|
|
372
|
+
end
|
|
373
|
+
|
|
278
374
|
# Active Storage routes are drawn on the host app, not on this isolated
|
|
279
375
|
# engine. A bare `image_tag variant` is fine in normal host views, but
|
|
280
376
|
# inside engine views it asks the engine route set to polymorphically
|
|
@@ -76,8 +76,11 @@ export default class extends Controller {
|
|
|
76
76
|
// are disabled while editing (edits are body-only by design).
|
|
77
77
|
|
|
78
78
|
beginEdit(event) {
|
|
79
|
-
const { id, body } = event.detail || {}
|
|
79
|
+
const { id, body, thread } = event.detail || {}
|
|
80
80
|
if (!id || !this.hasInputTarget) return
|
|
81
|
+
// Several threads can share a page (the dock's windows): only the
|
|
82
|
+
// composer of the thread the message lives in edits it.
|
|
83
|
+
if (thread && !thread.contains(this.element)) return
|
|
81
84
|
|
|
82
85
|
this.createAction ||= this.element.action
|
|
83
86
|
this.element.action = `${this.createAction}/${id}`
|
|
@@ -112,6 +115,16 @@ export default class extends Controller {
|
|
|
112
115
|
this.inputTarget.setSelectionRange(this.inputTarget.value.length, this.inputTarget.value.length)
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
// Escape while editing cancels the edit, and only that: the dock (if the
|
|
119
|
+
// composer is in one of its windows) sees the event handled and leaves
|
|
120
|
+
// the window alone.
|
|
121
|
+
escape(event) {
|
|
122
|
+
if (!this.editing) return
|
|
123
|
+
|
|
124
|
+
event.preventDefault()
|
|
125
|
+
this.cancelEdit()
|
|
126
|
+
}
|
|
127
|
+
|
|
115
128
|
cancelEdit(event) {
|
|
116
129
|
event?.preventDefault()
|
|
117
130
|
this.exitEdit()
|