chats 0.4.2 → 0.5.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 +247 -0
- data/README.md +95 -8
- data/app/assets/stylesheets/chats.css +596 -0
- data/app/controllers/chats/conversations_controller.rb +71 -5
- data/app/helpers/chats/engine_helper.rb +75 -2
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1070 -0
- data/app/javascript/chats/thread_controller.js +72 -6
- 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/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +172 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +2 -1
- 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 +130 -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/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +17 -0
- data/config/locales/es.yml +17 -0
- data/config/routes.rb +5 -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 +76 -10
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +19 -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 +18 -2
|
@@ -2,8 +2,12 @@
|
|
|
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
|
+
|
|
7
11
|
before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
|
|
8
12
|
helper_method :chats_counterpart
|
|
9
13
|
|
|
@@ -13,6 +17,11 @@ module Chats
|
|
|
13
17
|
# Rows are Conversation | InboxGroup: stacking, search and the `?with=`
|
|
14
18
|
# filter all live in Chats::Inbox, so this action stays three lines.
|
|
15
19
|
def index
|
|
20
|
+
# The dock's search form posts here, so that without JavaScript it
|
|
21
|
+
# lands on the full inbox's results; from the dock's frame, the answer
|
|
22
|
+
# is the dock's own list.
|
|
23
|
+
return dock if turbo_frame_request_id == "chats_dock_list"
|
|
24
|
+
|
|
16
25
|
@inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
|
|
17
26
|
@rows = @inbox.rows
|
|
18
27
|
@unread_counts = @inbox.unread_counts
|
|
@@ -23,9 +32,25 @@ module Chats
|
|
|
23
32
|
@conversations = @inbox.conversations
|
|
24
33
|
end
|
|
25
34
|
|
|
35
|
+
# The messaging dock's recent list (`chats_dock`): the first rows of the
|
|
36
|
+
# viewer's inbox — the same rows, stacks and search, through the same
|
|
37
|
+
# Chats::Inbox — rendered into the dock's `chats_dock_list` frame. The
|
|
38
|
+
# frame asks for `?recent=` rows (the helper's `recent:` option).
|
|
39
|
+
def dock
|
|
40
|
+
@inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], limit: dock_recent)
|
|
41
|
+
@rows = @inbox.rows
|
|
42
|
+
@unread_counts = @inbox.unread_counts
|
|
43
|
+
|
|
44
|
+
render :dock, layout: false
|
|
45
|
+
end
|
|
46
|
+
|
|
26
47
|
# The thread. Renders the LATEST page of messages; older pages stream in
|
|
27
48
|
# through a lazy Turbo Frame chain (keyset-paginated — see
|
|
28
49
|
# Message.before_message and _messages_page.html.erb).
|
|
50
|
+
#
|
|
51
|
+
# `?window=1` from a Turbo frame is the same thread as a floating dock
|
|
52
|
+
# window: the window template (its compact header around the same
|
|
53
|
+
# thread body), no layout.
|
|
29
54
|
def show
|
|
30
55
|
@participant = @conversation.participant_for(chats_current_messager)
|
|
31
56
|
|
|
@@ -55,9 +80,17 @@ module Chats
|
|
|
55
80
|
@messages.first&.id
|
|
56
81
|
end
|
|
57
82
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
if dock_window?
|
|
84
|
+
# A window does NOT read on render: it may be restored minimized
|
|
85
|
+
# after a reload, and a header nobody has looked at must not
|
|
86
|
+
# swallow the unread badge. The thread controller posts #read once
|
|
87
|
+
# the window is actually on screen (`read-pending`).
|
|
88
|
+
render :window, layout: false
|
|
89
|
+
elsif !prefetch?
|
|
90
|
+
# Opening the thread reads it. (Live appends while the thread
|
|
91
|
+
# stays open are read via the thread controller's POST to #read.)
|
|
92
|
+
@participant&.read!
|
|
93
|
+
end
|
|
61
94
|
end
|
|
62
95
|
end
|
|
63
96
|
|
|
@@ -106,6 +139,12 @@ module Chats
|
|
|
106
139
|
# `chat_button_to` helper — unforgeable and purpose-scoped, so raw
|
|
107
140
|
# polymorphic params never reach `GlobalID::Locator`. Policy and block
|
|
108
141
|
# checks still run inside `chat_with` (defense in depth).
|
|
142
|
+
#
|
|
143
|
+
# `dock=1` (`chat_button_to …, dock: true`) answers a Turbo Stream that
|
|
144
|
+
# appends the conversation's floating window to the dock instead of
|
|
145
|
+
# navigating. Same id, so a window already open is replaced, not
|
|
146
|
+
# doubled; on a page without the dock the stream has no target and does
|
|
147
|
+
# nothing. Without Turbo it is the plain redirect.
|
|
109
148
|
def create
|
|
110
149
|
recipient = locate_signed!(params.require(:recipient_sgid), purpose: :chats_recipient)
|
|
111
150
|
raise ActiveRecord::RecordNotFound unless Chats.messager_class?(recipient.class)
|
|
@@ -114,7 +153,16 @@ module Chats
|
|
|
114
153
|
locate_signed!(params[:subject_sgid], purpose: :chats_subject)
|
|
115
154
|
|
|
116
155
|
conversation = chats_current_messager.chat_with(recipient, about: subject)
|
|
117
|
-
|
|
156
|
+
|
|
157
|
+
if params[:dock].present? && request.format.turbo_stream?
|
|
158
|
+
render turbo_stream: turbo_stream.append(
|
|
159
|
+
"chats_dock_windows",
|
|
160
|
+
partial: "chats/dock/window",
|
|
161
|
+
locals: { conversation: conversation, focus: true }
|
|
162
|
+
)
|
|
163
|
+
else
|
|
164
|
+
redirect_to conversation_path(conversation)
|
|
165
|
+
end
|
|
118
166
|
rescue Chats::BlockedError
|
|
119
167
|
# Fallback is the INBOX (engine root) — inside the engine, `root_path`
|
|
120
168
|
# already resolves there, never to the host root.
|
|
@@ -165,6 +213,24 @@ module Chats
|
|
|
165
213
|
@conversation = find_conversation
|
|
166
214
|
end
|
|
167
215
|
|
|
216
|
+
# A dock window asks for its thread from inside its own turbo frame. A
|
|
217
|
+
# plain visit to the same URL (no frame) still gets the page.
|
|
218
|
+
def dock_window?
|
|
219
|
+
params[:window].present? && turbo_frame_request?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Turbo 8 prefetches a link when the pointer rests on it, and a pointer
|
|
223
|
+
# resting on an inbox row is not somebody reading the thread. The visit
|
|
224
|
+
# itself, if it happens, reads.
|
|
225
|
+
# https://turbo.hotwired.dev/handbook/drive#prefetching-links-on-hover
|
|
226
|
+
def prefetch?
|
|
227
|
+
request.headers["X-Sec-Purpose"] == "prefetch" || request.headers["Sec-Purpose"]&.include?("prefetch")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def dock_recent
|
|
231
|
+
Integer(params[:recent], exception: false)&.clamp(1, DOCK_RECENT_LIMIT) || 10
|
|
232
|
+
end
|
|
233
|
+
|
|
168
234
|
def locate_signed!(sgid, purpose:)
|
|
169
235
|
GlobalID::Locator.locate_signed(sgid, for: purpose) || raise(ActiveRecord::RecordNotFound)
|
|
170
236
|
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"),
|
|
@@ -59,6 +66,67 @@ module Chats
|
|
|
59
66
|
])
|
|
60
67
|
end
|
|
61
68
|
|
|
69
|
+
# The messaging dock, LinkedIn / Steam style: a bar in the bottom-right
|
|
70
|
+
# corner with the unread badge, a panel of recent conversations, and
|
|
71
|
+
# floating conversation windows. One line in the host's layout:
|
|
72
|
+
#
|
|
73
|
+
# <%= chats_dock %>
|
|
74
|
+
#
|
|
75
|
+
# Renders nothing for a signed-out visitor, and nothing on the engine's
|
|
76
|
+
# own pages (the inbox, a thread): those already ARE the messaging UI,
|
|
77
|
+
# and a window there would duplicate the thread's DOM ids the live
|
|
78
|
+
# streams address. The dock is permanent across Turbo visits, so the
|
|
79
|
+
# server can't take it away on the next page: it also hides ITSELF on
|
|
80
|
+
# any page carrying `data-chats-dock-hide` (the engine's pages do; put
|
|
81
|
+
# it on your own messaging pages). `everywhere: true` renders it on the
|
|
82
|
+
# engine's pages and ignores data-chats-dock-hide (it still never opens
|
|
83
|
+
# a window for a conversation the page itself shows).
|
|
84
|
+
#
|
|
85
|
+
# windows: how many windows may be open at once (3, and never more
|
|
86
|
+
# than fit beside the panel: 2 at 1280px, 1 at 768px)
|
|
87
|
+
# recent: how many conversations the panel lists (10)
|
|
88
|
+
# launcher: false drops the phone's floating button, for a host that
|
|
89
|
+
# opens the sheet from its own nav (see chats_dock_toggle)
|
|
90
|
+
def chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false)
|
|
91
|
+
viewer = chats_viewer
|
|
92
|
+
return if viewer.nil?
|
|
93
|
+
return if chats_engine_page? && !everywhere
|
|
94
|
+
|
|
95
|
+
render(partial: "chats/dock/dock",
|
|
96
|
+
locals: { viewer: viewer, windows: windows, recent: recent, launcher: launcher, everywhere: everywhere })
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The data that makes ANY link to a conversation — a recent-list row, a
|
|
100
|
+
# host's own link — open that conversation's dock window instead of
|
|
101
|
+
# navigating, when a dock is on the page. The link stays a real link: a
|
|
102
|
+
# modified click, a page without the dock, or no JavaScript navigates.
|
|
103
|
+
#
|
|
104
|
+
# <%= link_to "Chat", chats.conversation_path(c), data: chats_dock_opener(c) %>
|
|
105
|
+
def chats_dock_opener(conversation)
|
|
106
|
+
{ chats_dock_conversation_id: conversation.id }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The data that makes a host's own Messages link open the dock's panel
|
|
110
|
+
# (the full-screen sheet on a phone) instead of navigating, when a dock
|
|
111
|
+
# is on the page. Pair it with `chats_dock(launcher: false)` to replace
|
|
112
|
+
# the phone's floating button with your nav's. Without the dock, or
|
|
113
|
+
# without JavaScript, the link goes where its href says.
|
|
114
|
+
#
|
|
115
|
+
# <%= link_to "Messages", chats.conversations_path, data: chats_dock_toggle %>
|
|
116
|
+
def chats_dock_toggle
|
|
117
|
+
{ chats_dock_toggle: true }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Whether +viewer+ muted +conversation+, read from the preloaded seats
|
|
121
|
+
# (the inbox loads them), so a row costs no query for it.
|
|
122
|
+
def chats_muted?(conversation, viewer)
|
|
123
|
+
return false if viewer.nil?
|
|
124
|
+
|
|
125
|
+
conversation.participants.any? do |seat|
|
|
126
|
+
seat.muted? && seat.messager_type == viewer.class.polymorphic_name && seat.messager_id == viewer.id
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
62
130
|
# An avatar for any messager: whatever `config.messager_avatar` returns
|
|
63
131
|
# (URL / ActiveStorage attachment / variant) or an initials placeholder.
|
|
64
132
|
def chats_messager_avatar(messager, css_class: "chats-avatar", loading: "eager")
|
|
@@ -148,7 +216,7 @@ module Chats
|
|
|
148
216
|
# app/views/chats/slots/_inbox_top.html.erb
|
|
149
217
|
#
|
|
150
218
|
# The slots: inbox_top, inbox_empty, conversation_header_actions,
|
|
151
|
-
# composer_actions, locked_composer, message_meta.
|
|
219
|
+
# composer_actions, locked_composer, message_meta, dock_panel_footer.
|
|
152
220
|
def chats_slot(name, **locals)
|
|
153
221
|
return unless chats_slot?(name)
|
|
154
222
|
|
|
@@ -275,6 +343,11 @@ module Chats
|
|
|
275
343
|
respond_to?(method_name) ? send(method_name) : nil
|
|
276
344
|
end
|
|
277
345
|
|
|
346
|
+
# Whether this view is one of the engine's own screens.
|
|
347
|
+
def chats_engine_page?
|
|
348
|
+
respond_to?(:controller) && controller.is_a?(Chats::ApplicationController)
|
|
349
|
+
end
|
|
350
|
+
|
|
278
351
|
# Active Storage routes are drawn on the host app, not on this isolated
|
|
279
352
|
# engine. A bare `image_tag variant` is fine in normal host views, but
|
|
280
353
|
# 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()
|