chats 0.4.1 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +276 -0
  3. data/README.md +96 -9
  4. data/app/assets/stylesheets/chats.css +596 -0
  5. data/app/controllers/chats/conversations_controller.rb +71 -5
  6. data/app/helpers/chats/engine_helper.rb +75 -2
  7. data/app/javascript/chats/composer_controller.js +14 -1
  8. data/app/javascript/chats/dock_controller.js +1070 -0
  9. data/app/javascript/chats/thread_controller.js +72 -6
  10. data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
  11. data/app/views/chats/conversations/_group.html.erb +6 -2
  12. data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
  13. data/app/views/chats/conversations/_thread.html.erb +172 -0
  14. data/app/views/chats/conversations/dock.html.erb +40 -0
  15. data/app/views/chats/conversations/index.html.erb +2 -1
  16. data/app/views/chats/conversations/show.html.erb +11 -118
  17. data/app/views/chats/conversations/window.html.erb +26 -0
  18. data/app/views/chats/dock/_badge.html.erb +12 -0
  19. data/app/views/chats/dock/_dock.html.erb +130 -0
  20. data/app/views/chats/dock/_signal.html.erb +26 -0
  21. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  22. data/app/views/chats/dock/_window.html.erb +23 -0
  23. data/app/views/chats/dock/_window_header.html.erb +75 -0
  24. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  25. data/app/views/chats/messages/_composer.html.erb +11 -1
  26. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  27. data/config/importmap.rb +1 -0
  28. data/config/locales/en.yml +18 -0
  29. data/config/locales/es.yml +18 -0
  30. data/config/routes.rb +5 -0
  31. data/docs/screenshots/dock-bar.png +0 -0
  32. data/docs/screenshots/dock-desktop.png +0 -0
  33. data/docs/screenshots/dock-phone-button.png +0 -0
  34. data/docs/screenshots/dock-phone-panel.png +0 -0
  35. data/docs/screenshots/dock-phone-window.png +0 -0
  36. data/gemfiles/rails_7.1.gemfile +2 -0
  37. data/gemfiles/rails_7.2.gemfile +2 -0
  38. data/gemfiles/rails_8.1.gemfile +2 -0
  39. data/lib/chats/broadcasts.rb +76 -10
  40. data/lib/chats/inbox.rb +68 -30
  41. data/lib/chats/models/concerns/messager.rb +10 -2
  42. data/lib/chats/models/conversation.rb +19 -0
  43. data/lib/chats/models/message.rb +32 -8
  44. data/lib/chats/version.rb +1 -1
  45. data/lib/generators/chats/templates/initializer.rb +1 -0
  46. data/lib/generators/chats/views_generator.rb +3 -2
  47. 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), and the per-member actions (read/typing/leave/mute).
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
- # Opening the thread reads it. (Live appends while the thread stays
59
- # open are read via the thread controller's POST to #read.)
60
- @participant&.read!
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
- redirect_to conversation_path(conversation)
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()