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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +478 -0
  3. data/README.md +150 -10
  4. data/app/assets/stylesheets/chats.css +599 -0
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +273 -55
  7. data/app/helpers/chats/engine_helper.rb +102 -6
  8. data/app/javascript/chats/composer_controller.js +14 -1
  9. data/app/javascript/chats/dock_controller.js +1106 -0
  10. data/app/javascript/chats/recovery.js +539 -0
  11. data/app/javascript/chats/refresh_inbox_controller.js +55 -65
  12. data/app/javascript/chats/thread_controller.js +317 -60
  13. data/app/javascript/chats/unread_badge_controller.js +24 -0
  14. data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
  15. data/app/views/chats/conversations/_group.html.erb +6 -2
  16. data/app/views/chats/conversations/_history.html.erb +54 -0
  17. data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
  18. data/app/views/chats/conversations/_thread.html.erb +133 -0
  19. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  20. data/app/views/chats/conversations/dock.html.erb +40 -0
  21. data/app/views/chats/conversations/index.html.erb +24 -10
  22. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  23. data/app/views/chats/conversations/show.html.erb +11 -118
  24. data/app/views/chats/conversations/window.html.erb +26 -0
  25. data/app/views/chats/dock/_badge.html.erb +12 -0
  26. data/app/views/chats/dock/_dock.html.erb +138 -0
  27. data/app/views/chats/dock/_signal.html.erb +26 -0
  28. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  29. data/app/views/chats/dock/_window.html.erb +23 -0
  30. data/app/views/chats/dock/_window_header.html.erb +75 -0
  31. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  32. data/app/views/chats/messages/_composer.html.erb +11 -1
  33. data/app/views/chats/messages/_message.html.erb +8 -6
  34. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  35. data/config/importmap.rb +7 -0
  36. data/config/locales/en.yml +17 -0
  37. data/config/locales/es.yml +17 -0
  38. data/config/routes.rb +8 -0
  39. data/docs/screenshots/dock-bar.png +0 -0
  40. data/docs/screenshots/dock-desktop.png +0 -0
  41. data/docs/screenshots/dock-phone-button.png +0 -0
  42. data/docs/screenshots/dock-phone-panel.png +0 -0
  43. data/docs/screenshots/dock-phone-window.png +0 -0
  44. data/gemfiles/rails_7.1.gemfile +2 -0
  45. data/gemfiles/rails_7.2.gemfile +2 -0
  46. data/gemfiles/rails_8.1.gemfile +2 -0
  47. data/lib/chats/broadcasts.rb +178 -17
  48. data/lib/chats/inbox.rb +68 -30
  49. data/lib/chats/inbox_refresh_job.rb +14 -0
  50. data/lib/chats/models/concerns/messager.rb +10 -2
  51. data/lib/chats/models/conversation.rb +57 -0
  52. data/lib/chats/models/message.rb +98 -9
  53. data/lib/chats/models/reaction.rb +13 -0
  54. data/lib/chats/version.rb +1 -1
  55. data/lib/generators/chats/templates/initializer.rb +1 -0
  56. data/lib/generators/chats/views_generator.rb +3 -2
  57. 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), 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
+ # 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 = @conversation.messages.includes(:sender, :reactions)
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). Anchored to the
50
- # oldest unread bubble on the rendered page; when the backlog runs
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
- # 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!
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: appends messages created — and replaces ones
65
- # edited/tombstoned — since the newest `updated_at` the client has
66
- # rendered (`?since=` in ms). The thread controller calls this when the
67
- # tab wakes from a long sleep or its Turbo Stream subscription
68
- # reconnects, i.e. whenever broadcasts may have been missed. Mobile
69
- # WebViews suspend WebSockets aggressively, so without this a
70
- # backgrounded chat silently loses messages until a manual reload.
71
- # Pattern from Basecamp's Campfire (Rooms::RefreshesController):
72
- # https://github.com/basecamp/once-campfire
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
- since = Time.zone.at(0, params[:since].to_i, :millisecond)
77
- scope = @conversation.messages.includes(:sender, :reactions)
78
- scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
79
-
80
- @new_messages = scope.created_since(since).oldest_first.limit(Chats.config.messages_per_page + 1).to_a
81
-
82
- # A backlog deeper than one page would mean splicing an arbitrary
83
- # amount of history through surgical appends; a Turbo 8 page refresh
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
- @updated_messages = scope.updated_since(since)
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
- redirect_to conversation_path(conversation)
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
- turbo_stream_from(messager, :chats_badge),
58
- render(partial: "chats/shared/unread_badge", locals: { count: messager.unread_chats_count })
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()