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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +247 -0
  3. data/README.md +95 -8
  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 +17 -0
  29. data/config/locales/es.yml +17 -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/version.rb +1 -1
  44. data/lib/generators/chats/templates/initializer.rb +1 -0
  45. data/lib/generators/chats/views_generator.rb +3 -2
  46. metadata +18 -2
@@ -36,9 +36,17 @@ const TRIM_LEEWAY = 20
36
36
  // already near the bottom (never yank them up
37
37
  // mid-history-read)
38
38
  // * mark-as-read debounce a POST to #read when foreign messages
39
- // arrive while the tab is visible — that POST
40
- // advances our read horizon and broadcasts fresh
41
- // read-state to everyone
39
+ // arrive while the thread is on screen — that
40
+ // POST advances our read horizon and broadcasts
41
+ // fresh read-state to everyone. Only while the
42
+ // viewer can actually be reading: a visible tab,
43
+ // a thread with room to show itself (a minimized
44
+ // dock window has none), and — inside a dock
45
+ // window — one the viewer is engaged with (the
46
+ // dock's `data-chats-active` gate; a window that
47
+ // popped open by itself waits for a focus, a
48
+ // press or a scroll). A dock window is rendered
49
+ // unread (`readPending`) and reads the same way.
42
50
  // * sent / seen receipts derive per-message ticks client-side from the
43
51
  // broadcast read-state payload
44
52
  // * day separators recalculate local-calendar markers after the
@@ -74,11 +82,16 @@ export default class extends Controller {
74
82
  daySeparatorClass: { type: String, default: "chats-day-separator" },
75
83
  typingSuffix: String,
76
84
  copiedLabel: String,
77
- group: Boolean
85
+ group: Boolean,
86
+ readPending: Boolean
78
87
  }
79
88
 
80
89
  connect() {
81
90
  this.registerTypingStreamAction()
91
+ // The dock's engagement gate: the window frame around a dock thread.
92
+ // No gate (the thread page) means always engaged.
93
+ this.gate = this.element.closest("[data-chats-active]")
94
+ this.gate?.addEventListener("chats:activated", this.flushPendingRead)
82
95
 
83
96
  // targetConnected fires for every server-rendered bubble right after
84
97
  // connect; this flag keeps the initial flood from triggering N scrolls
@@ -90,6 +103,7 @@ export default class extends Controller {
90
103
  this.renderReceipts()
91
104
  this.renderDaySeparators()
92
105
  this.renderMessageGroups()
106
+ if (this.readPendingValue) this.queueRead()
93
107
  })
94
108
 
95
109
  if (this.hasTypingTarget) {
@@ -111,6 +125,7 @@ export default class extends Controller {
111
125
  }
112
126
  this.element.addEventListener("click", this.suppressClickCapture, true)
113
127
  this.watchStreamSource()
128
+ this.watchScreenPresence()
114
129
  }
115
130
 
116
131
  disconnect() {
@@ -120,6 +135,8 @@ export default class extends Controller {
120
135
  document.removeEventListener("visibilitychange", this.visibilityChanged)
121
136
  clearTimeout(this.readTimer)
122
137
  this.sourceObserver?.disconnect()
138
+ this.presenceObserver?.disconnect()
139
+ this.gate?.removeEventListener("chats:activated", this.flushPendingRead)
123
140
  this.element.removeEventListener("click", this.suppressClickCapture, true)
124
141
  clearTimeout(this.pressTimer)
125
142
  this.teardownPopup()
@@ -350,7 +367,7 @@ export default class extends Controller {
350
367
  // --- Mark-as-read ------------------------------------------------------------
351
368
 
352
369
  queueRead() {
353
- if (document.visibilityState !== "visible") {
370
+ if (!this.canRead()) {
354
371
  this.pendingRead = true
355
372
  return
356
373
  }
@@ -375,8 +392,54 @@ export default class extends Controller {
375
392
  }
376
393
  }
377
394
 
395
+ // Whether the viewer can be reading this thread right now.
396
+ canRead() {
397
+ return document.visibilityState === "visible" && this.engaged() && this.onScreen()
398
+ }
399
+
400
+ // The thread has room to show itself. Geometry alone, for any host that
401
+ // collapses a thread its own way; the dock's own state is engaged()'s job.
402
+ onScreen() {
403
+ return !this.hasScrollerTarget || this.scrollerTarget.clientHeight > 0
404
+ }
405
+
406
+ // Inside a dock window, what the dock says, explicitly: the window is
407
+ // neither minimized (data-chats-minimized) nor waiting for the viewer to
408
+ // turn to it (data-chats-active="false"). Geometry can't be trusted for
409
+ // the first: a folded window's scroller keeps whatever padding a host
410
+ // gives it. No gate (the thread page): always engaged.
411
+ engaged() {
412
+ if (!this.gate) return true
413
+
414
+ return this.gate.dataset.chatsActive !== "false" && !this.gate.hasAttribute("data-chats-minimized")
415
+ }
416
+
417
+ // A read that waited for the thread to be seen happens the moment it is:
418
+ // a minimized window expanding gives its scroller a height, and the dock
419
+ // says when the viewer turns to a window. It lands on the newest message,
420
+ // which is what arrived.
421
+ flushPendingRead = () => {
422
+ if (!this.pendingRead || !this.canRead()) return
423
+ this.pendingRead = false
424
+ this.scrollToBottom()
425
+ this.queueRead()
426
+ }
427
+
428
+ watchScreenPresence() {
429
+ if (!this.hasScrollerTarget || typeof ResizeObserver === "undefined") return
430
+
431
+ this.presenceObserver = new ResizeObserver(this.flushPendingRead)
432
+ this.presenceObserver.observe(this.scrollerTarget)
433
+ }
434
+
378
435
  postRead() {
379
436
  if (!this.readUrlValue) return
437
+ // Asked again when the timer fires: the window may have been minimized
438
+ // (or the tab hidden) in the 700ms since the message arrived.
439
+ if (!this.canRead()) {
440
+ this.pendingRead = true
441
+ return
442
+ }
380
443
  fetch(this.readUrlValue, {
381
444
  method: "POST",
382
445
  headers: { "X-CSRF-Token": csrfToken(), Accept: "application/json" }
@@ -822,8 +885,11 @@ export default class extends Controller {
822
885
  const messageId = bubble.id.split("_").pop()
823
886
 
824
887
  this.closePopup()
888
+ // `thread`: a page can hold several threads (the page's and the dock's
889
+ // windows), each with its own composer listening on window — the
890
+ // composer inside THIS thread is the one that edits.
825
891
  window.dispatchEvent(new CustomEvent("chats:edit-message", {
826
- detail: { id: messageId, body: body }
892
+ detail: { id: messageId, body: body, thread: this.element }
827
893
  }))
828
894
  }
829
895
 
@@ -1,8 +1,14 @@
1
1
  <%# One inbox row. Rendered per-viewer (this page only updates via full
2
2
  refreshes, never shared broadcasts, so viewer-specific state is fine here —
3
- unlike message bubbles, which must stay viewer-agnostic). %>
3
+ unlike message bubbles, which must stay viewer-agnostic).
4
+
5
+ `dock: true` (the messaging dock's recent list) keeps the link a link to
6
+ the thread and marks it as a dock opener: a plain click opens the
7
+ conversation's floating window instead. A muted conversation carries
8
+ the bell-slash. %>
4
9
  <li class="chats-row <%= "chats-row--unread" if unread_count.positive? %>">
5
- <%= link_to conversation_path(conversation), class: "chats-row__link" do %>
10
+ <%= link_to conversation_path(conversation), class: "chats-row__link",
11
+ data: (chats_dock_opener(conversation) if local_assigns[:dock]) do %>
6
12
  <%= chats_conversation_avatar(conversation, viewer) %>
7
13
 
8
14
  <span class="chats-row__body">
@@ -13,6 +19,11 @@
13
19
  <span class="chats-row__name">
14
20
  <span class="chats-row__title"><%= conversation.title_for(viewer) %></span>
15
21
  <%= chats_verified_badge(conversation.counterpart_for(viewer)) %>
22
+ <% if chats_muted?(conversation, viewer) %>
23
+ <svg class="chats-row__muted" role="img" aria-label="<%= t("chats.dock.muted") %>" viewBox="0 0 24 24"
24
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
25
+ focusable="false"><title><%= t("chats.dock.muted") %></title><path d="M13.7 21a2 2 0 0 1-3.4 0M18.6 13A17.9 17.9 0 0 1 18 8M6.3 6.3A5.9 5.9 0 0 0 6 8c0 7-3 9-3 9h14M18 8a6 6 0 0 0-9.3-5M3 3l18 18"/></svg>
26
+ <% end %>
16
27
  </span>
17
28
  <time class="chats-row__time" datetime="<%= conversation.last_message_at&.iso8601 %>">
18
29
  <%= chats_timestamp(conversation.last_message_at) %>
@@ -5,10 +5,14 @@
5
5
 
6
6
  A stack of one links straight to that thread (and the thread offers a
7
7
  "see all" link back here); a deeper stack opens the stack list, which is
8
- either the host's `group_path:` or chats' own filtered inbox. %>
8
+ either the host's `group_path:` or chats' own filtered inbox.
9
+
10
+ In the messaging dock (`dock: true`) a stack of one opens its thread's
11
+ floating window; a deeper stack still goes to the stack list. %>
9
12
  <li class="chats-row chats-row--group <%= "chats-row--unread" if group.unread? %>"
10
13
  id="<%= group.dom_id %>">
11
- <%= link_to chats_group_path(group), class: "chats-row__link" do %>
14
+ <%= link_to chats_group_path(group), class: "chats-row__link",
15
+ data: (chats_dock_opener(group.conversation) if local_assigns[:dock] && group.single?) do %>
12
16
  <%= chats_messager_avatar(group.messager) %>
13
17
 
14
18
  <span class="chats-row__body">
@@ -5,11 +5,16 @@
5
5
  composer — the host's composer_actions leave with it.
6
6
 
7
7
  The `locked_composer` slot replaces the BODY (a reopen button, a link to
8
- the closed ticket…) while keeping the id contract intact. %>
8
+ the closed ticket…) while keeping the id contract intact.
9
+
10
+ `notice:` replaces the subject's reason: the thread renders this same
11
+ box, with "You can't message this person.", for a direct thread the
12
+ host's policy no longer allows (or whose other account is gone). %>
13
+ <% notice = local_assigns[:notice] || conversation.locked_notice %>
9
14
  <div id="<%= dom_id(conversation, :composer) %>" class="chats-composer chats-composer--locked">
10
15
  <% if chats_slot?(:locked_composer) %>
11
- <%= chats_slot :locked_composer, conversation: conversation, notice: conversation.locked_notice %>
16
+ <%= chats_slot :locked_composer, conversation: conversation, notice: notice %>
12
17
  <% else %>
13
- <p class="chats-composer__locked-notice"><%= conversation.locked_notice %></p>
18
+ <p class="chats-composer__locked-notice"><%= notice %></p>
14
19
  <% end %>
15
20
  </div>
@@ -0,0 +1,172 @@
1
+ <%# The thread body: everything live about one open conversation, shared by
2
+ the thread PAGE (show.html.erb) and a floating dock WINDOW
3
+ (window.html.erb). Rendered as a partial LAYOUT — `yield` is the header,
4
+ which is the one thing the two differ in: the page keeps its full header
5
+ (back link, ⋯ menu), the window its compact one (minimize, close, open
6
+ full page).
7
+
8
+ One conversation stream powers everything here: message
9
+ append/replace/remove, the read-state payload, and typing pings. Bubbles
10
+ arrive VIEWER-AGNOSTIC from shared broadcasts; the chats--thread Stimulus
11
+ controller aligns own-vs-other by comparing each bubble's sender key
12
+ against its `me` value (that's what makes one broadcast render work for
13
+ every subscriber).
14
+
15
+ The DOM ids (messages, composer, composer_errors, read_state, typing) are
16
+ the targets the streams address, so a conversation is rendered at most
17
+ ONCE per page: the dock never opens a window for a conversation the page
18
+ already shows.
19
+
20
+ Locals:
21
+ conversation the Chats::Conversation
22
+ messages the latest page, oldest first
23
+ more whether older pages exist (the lazy frame chain)
24
+ first_unread_id where the «new messages» line goes, or nil
25
+ counterpart the other messager of a direct thread, or nil
26
+ window true in a dock window: rooted at .chats-thread--window
27
+ (never .chats, which is the full-page shell), and the
28
+ server did NOT read this render — the controller
29
+ posts #read once the window is on screen and the
30
+ viewer is engaged with it (`readPending`).
31
+
32
+ The page's root carries data-chats-dock-hide: the messaging dock hides
33
+ itself on any page that has it. %>
34
+ <% window = local_assigns.fetch(:window, false) %>
35
+ <% viewer = chats_current_messager %>
36
+ <%# A direct thread nobody may write in any more says so up front, in the
37
+ composer's place — the host's policy turned false, or the other account
38
+ is gone — instead of letting a send fail. Same id as the composer, so
39
+ the live lock swap still finds it. %>
40
+ <% refused = !conversation.locked? && conversation.direct? &&
41
+ (counterpart.nil? || !Chats.can_message?(viewer, counterpart)) %>
42
+ <%= tag.div class: (window ? "chats-thread chats-thread--window" : "chats chats-thread"), data: {
43
+ controller: "chats--thread",
44
+ "chats-dock-hide": (true unless window),
45
+ "chats--thread-me-value": Chats.messager_key(viewer),
46
+ "chats--thread-read-url-value": read_conversation_path(conversation),
47
+ "chats--thread-read-pending-value": window,
48
+ "chats--thread-sent-label-value": t("chats.thread.sent"),
49
+ "chats--thread-seen-label-value": Chats.config.read_receipts ? t("chats.thread.seen") : "",
50
+ "chats--thread-today-label-value": t("chats.thread.today"),
51
+ "chats--thread-yesterday-label-value": t("chats.thread.yesterday"),
52
+ "chats--thread-typing-suffix-value": t("chats.thread.typing_suffix"),
53
+ "chats--thread-group-value": conversation.group?,
54
+ "chats--thread-refresh-url-value": refresh_conversation_path(conversation),
55
+ "chats--thread-thread-url-value": conversation_path(conversation),
56
+ "chats--thread-copied-label-value": t("chats.message.copied"),
57
+ # A plain data attribute (not a Stimulus value): host CSS/JS keys its
58
+ # own block/report affordances off it. False for a messager declared
59
+ # `acts_as_messager blockable: false`.
60
+ "chats-blockable": chats_blockable?(counterpart),
61
+ action: "pointerdown->chats--thread#pressStart pointermove->chats--thread#pressMove " \
62
+ "pointerup->chats--thread#pressEnd pointercancel->chats--thread#pressCancel " \
63
+ "contextmenu->chats--thread#contextMenu"
64
+ } do %>
65
+
66
+ <%= turbo_stream_from conversation %>
67
+
68
+ <%= yield %>
69
+
70
+ <%# tabindex: the arrow keys scroll the history (WCAG 2.1.1). %>
71
+ <div class="chats-thread__scroll" data-chats--thread-target="scroller" tabindex="0">
72
+ <% if window && messages.empty? %>
73
+ <%# A window opened from a Message button, before anybody wrote: who
74
+ you're about to talk to. Gone with the first bubble (chats.css). %>
75
+ <div class="chats-window__hello">
76
+ <% if counterpart %>
77
+ <%= chats_messager_avatar(counterpart) %>
78
+ <strong><%= Chats.display_name_for(counterpart) %></strong>
79
+ <% end %>
80
+ <%= t("chats.dock.hello", name: conversation.title_for(viewer)) %>
81
+ </div>
82
+ <% end %>
83
+ <% if more && messages.any? %>
84
+ <%# Lazy frame chain for infinite scroll-up: when this frame becomes
85
+ visible, Turbo fetches the previous keyset page (?before=oldest id),
86
+ whose response contains a frame with this SAME id — older messages
87
+ plus, recursively, the next frame. See ConversationsController#show. %>
88
+ <%= turbo_frame_tag "chats_page_#{messages.first.id}",
89
+ src: conversation_path(conversation, before: messages.first.id),
90
+ loading: :lazy do %>
91
+ <div class="chats-loader"><%= t("chats.thread.loading") %></div>
92
+ <% end %>
93
+ <% end %>
94
+
95
+ <div id="<%= dom_id(conversation, :messages) %>" class="chats-thread__messages">
96
+ <%# Explicit loop (not a collection render) so the «new messages»
97
+ divider can sit BEFORE the first unread bubble (WhatsApp's "the
98
+ catch-up starts here" line). Server-rendered on the initial page
99
+ only; it scrolls away naturally as the thread lives on. %>
100
+ <% messages.each do |message| %>
101
+ <% if message.id == first_unread_id %>
102
+ <div class="chats-thread__unread-line" role="separator">
103
+ <span><%= t("chats.thread.new_messages") %></span>
104
+ </div>
105
+ <% end %>
106
+ <%= render "chats/messages/message", message: message %>
107
+ <% end %>
108
+ </div>
109
+
110
+ <%= render "chats/conversations/read_state", conversation: conversation %>
111
+
112
+ <div id="<%= dom_id(conversation, :typing) %>"
113
+ class="chats-typing"
114
+ data-chats--thread-target="typing"
115
+ hidden></div>
116
+ </div>
117
+
118
+ <%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
119
+ pressed bubble's clone morphs to the center, the reactions pill sits
120
+ above it and the contextual menu below. Slots are filled by cloning
121
+ the bubble's <template data-chats-message-menu>. %>
122
+ <div class="chats-popup"
123
+ role="dialog"
124
+ aria-modal="true"
125
+ data-chats--thread-target="popup"
126
+ hidden>
127
+ <div class="chats-popup__backdrop" data-action="click->chats--thread#closePopup"></div>
128
+ <div class="chats-popup__stack">
129
+ <div class="chats-popup__slot chats-popup__slot--reactions"
130
+ data-chats--thread-target="popupReactions"
131
+ data-action="click->chats--thread#popupMenuClicked"></div>
132
+ <div class="chats-popup__slot chats-popup__slot--bubble" data-chats--thread-target="popupBubble"></div>
133
+ <div class="chats-popup__slot chats-popup__slot--menu"
134
+ data-chats--thread-target="popupMenu"
135
+ data-action="click->chats--thread#popupMenuClicked"></div>
136
+ </div>
137
+ </div>
138
+
139
+ <div class="chats-attachment-preview"
140
+ role="dialog"
141
+ aria-modal="true"
142
+ tabindex="-1"
143
+ data-chats--thread-target="attachmentDialog"
144
+ data-action="keydown.esc->chats--thread#closeAttachment"
145
+ hidden>
146
+ <div class="chats-attachment-preview__surface"
147
+ data-action="click->chats--thread#closeAttachmentFromBackdrop">
148
+ <button type="button"
149
+ class="chats-attachment-preview__close"
150
+ aria-label="<%= t("chats.message.close_attachment") %>"
151
+ data-action="chats--thread#closeAttachment">×</button>
152
+ <figure class="chats-attachment-preview__figure">
153
+ <img class="chats-attachment-preview__image"
154
+ alt=""
155
+ data-chats--thread-target="attachmentImage">
156
+ </figure>
157
+ </div>
158
+ </div>
159
+
160
+ <%# A locked conversation (its SUBJECT says so — Chats::ChatSubject#
161
+ chat_locked?) keeps its whole history readable and swaps the composer
162
+ for the reason it's closed. Never a hidden screen: gate the action,
163
+ explain it in place. %>
164
+ <% if conversation.locked? %>
165
+ <%= render "chats/conversations/locked_composer", conversation: conversation %>
166
+ <% elsif refused %>
167
+ <%= render "chats/conversations/locked_composer", conversation: conversation,
168
+ notice: t("chats.flashes.blocked") %>
169
+ <% else %>
170
+ <%= render "chats/messages/composer", conversation: conversation %>
171
+ <% end %>
172
+ <% end %>
@@ -0,0 +1,40 @@
1
+ <%# The dock's recent list (GET /dock): the first rows of the viewer's inbox,
2
+ rendered into the dock's `chats_dock_list` frame with the inbox's own row
3
+ partials. `dock: true` makes each row open a floating window instead of
4
+ navigating — the link stays a real link to the thread, so a modified click
5
+ (new tab) or a page without the dock's JavaScript still gets there. The
6
+ inbox answers a search from the dock's form with this same template.
7
+
8
+ `target: "_top"`: anything else inside the frame (a stack's link, a row
9
+ from an inbox ejected before 0.5.0) navigates the page, not the frame. %>
10
+ <%= turbo_frame_tag "chats_dock_list", target: "_top" do %>
11
+ <% if @rows.any? %>
12
+ <ul class="chats-inbox__list chats-dock__list">
13
+ <% @rows.each do |row| %>
14
+ <% if row.is_a?(Chats::InboxGroup) %>
15
+ <%= render "chats/conversations/group", group: row, viewer: chats_current_messager, dock: true %>
16
+ <% else %>
17
+ <%= render "chats/conversations/conversation_row",
18
+ conversation: row,
19
+ viewer: chats_current_messager,
20
+ unread_count: @unread_counts.fetch(row.id, 0),
21
+ dock: true %>
22
+ <% end %>
23
+ <% end %>
24
+ </ul>
25
+ <% elsif @inbox.query %>
26
+ <div class="chats-empty">
27
+ <p class="chats-empty__title"><%= t("chats.inbox.no_results_title") %></p>
28
+ <p class="chats-empty__hint"><%= t("chats.inbox.no_results_hint", query: @inbox.query) %></p>
29
+ </div>
30
+ <% else %>
31
+ <%# The inbox's own empty state, the host's inbox_empty slot included:
32
+ the dock is how people find messaging, so it stays. %>
33
+ <div class="chats-empty">
34
+ <span class="chats-empty__icon" aria-hidden="true">💬</span>
35
+ <p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
36
+ <p class="chats-empty__hint"><%= t("chats.inbox.empty_hint") %></p>
37
+ <%= chats_slot :inbox_empty, viewer: chats_current_messager, inbox: @inbox %>
38
+ </div>
39
+ <% end %>
40
+ <% end %>
@@ -35,7 +35,8 @@
35
35
  <%= turbo_stream_from chats_current_messager, :chats_inbox %>
36
36
  </div>
37
37
 
38
- <div class="chats chats-inbox">
38
+ <%# data-chats-dock-hide: the messaging dock hides itself on this page. %>
39
+ <div class="chats chats-inbox" data-chats-dock-hide>
39
40
  <header class="chats-inbox__header">
40
41
  <h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
41
42
  </header>
@@ -1,35 +1,16 @@
1
- <%# The thread. One conversation stream powers everything live here:
2
- message append/replace/remove, the read-state payload, and typing pings.
3
- Bubbles arrive VIEWER-AGNOSTIC from shared broadcasts; the chats--thread
4
- Stimulus controller aligns own-vs-other by comparing each bubble's
5
- sender key against its `me` value (that's what makes one broadcast render
6
- work for every subscriber). %>
1
+ <%# The thread page: its own header around the shared thread body
2
+ (chats/conversations/_thread, which a floating dock window renders too —
3
+ see that partial for how the live thread works). %>
7
4
  <%= chats_styles %>
8
5
 
9
- <%= tag.div class: "chats chats-thread", data: {
10
- controller: "chats--thread",
11
- "chats--thread-me-value": Chats.messager_key(chats_current_messager),
12
- "chats--thread-read-url-value": read_conversation_path(@conversation),
13
- "chats--thread-sent-label-value": t("chats.thread.sent"),
14
- "chats--thread-seen-label-value": Chats.config.read_receipts ? t("chats.thread.seen") : "",
15
- "chats--thread-today-label-value": t("chats.thread.today"),
16
- "chats--thread-yesterday-label-value": t("chats.thread.yesterday"),
17
- "chats--thread-typing-suffix-value": t("chats.thread.typing_suffix"),
18
- "chats--thread-group-value": @conversation.group?,
19
- "chats--thread-refresh-url-value": refresh_conversation_path(@conversation),
20
- "chats--thread-thread-url-value": conversation_path(@conversation),
21
- "chats--thread-copied-label-value": t("chats.message.copied"),
22
- # A plain data attribute (not a Stimulus value): host CSS/JS keys its
23
- # own block/report affordances off it. False for a messager declared
24
- # `acts_as_messager blockable: false`.
25
- "chats-blockable": chats_blockable?(chats_counterpart),
26
- action: "pointerdown->chats--thread#pressStart pointermove->chats--thread#pressMove " \
27
- "pointerup->chats--thread#pressEnd pointercancel->chats--thread#pressCancel " \
28
- "contextmenu->chats--thread#contextMenu"
29
- } do %>
30
-
31
- <%= turbo_stream_from @conversation %>
32
-
6
+ <%= render layout: "chats/conversations/thread",
7
+ locals: {
8
+ conversation: @conversation,
9
+ messages: @messages,
10
+ more: @more_messages,
11
+ first_unread_id: @first_unread_id,
12
+ counterpart: chats_counterpart
13
+ } do %>
33
14
  <header class="chats-thread__header">
34
15
  <%= link_to root_path, class: "chats-thread__back", "aria-label": t("chats.thread.back") do %>
35
16
  <span aria-hidden="true">‹</span>
@@ -91,92 +72,4 @@
91
72
  </div>
92
73
  </details>
93
74
  </header>
94
-
95
- <div class="chats-thread__scroll" data-chats--thread-target="scroller">
96
- <% if @more_messages && @messages.any? %>
97
- <%# Lazy frame chain for infinite scroll-up: when this frame becomes
98
- visible, Turbo fetches the previous keyset page (?before=oldest id),
99
- whose response contains a frame with this SAME id — older messages
100
- plus, recursively, the next frame. See ConversationsController#show. %>
101
- <%= turbo_frame_tag "chats_page_#{@messages.first.id}",
102
- src: conversation_path(@conversation, before: @messages.first.id),
103
- loading: :lazy do %>
104
- <div class="chats-loader"><%= t("chats.thread.loading") %></div>
105
- <% end %>
106
- <% end %>
107
-
108
- <div id="<%= dom_id(@conversation, :messages) %>" class="chats-thread__messages">
109
- <%# Explicit loop (not a collection render) so the «new messages»
110
- divider can sit BEFORE the first unread bubble (WhatsApp's "the
111
- catch-up starts here" line). Server-rendered on the initial page
112
- only; it scrolls away naturally as the thread lives on. %>
113
- <% @messages.each do |message| %>
114
- <% if message.id == @first_unread_id %>
115
- <div class="chats-thread__unread-line" role="separator">
116
- <span><%= t("chats.thread.new_messages") %></span>
117
- </div>
118
- <% end %>
119
- <%= render "chats/messages/message", message: message %>
120
- <% end %>
121
- </div>
122
-
123
- <%= render "chats/conversations/read_state", conversation: @conversation %>
124
-
125
- <div id="<%= dom_id(@conversation, :typing) %>"
126
- class="chats-typing"
127
- data-chats--thread-target="typing"
128
- hidden></div>
129
- </div>
130
-
131
- <%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
132
- pressed bubble's clone morphs to the center, the reactions pill sits
133
- above it and the contextual menu below. Slots are filled by cloning
134
- the bubble's <template data-chats-message-menu>. %>
135
- <div class="chats-popup"
136
- role="dialog"
137
- aria-modal="true"
138
- data-chats--thread-target="popup"
139
- hidden>
140
- <div class="chats-popup__backdrop" data-action="click->chats--thread#closePopup"></div>
141
- <div class="chats-popup__stack">
142
- <div class="chats-popup__slot chats-popup__slot--reactions"
143
- data-chats--thread-target="popupReactions"
144
- data-action="click->chats--thread#popupMenuClicked"></div>
145
- <div class="chats-popup__slot chats-popup__slot--bubble" data-chats--thread-target="popupBubble"></div>
146
- <div class="chats-popup__slot chats-popup__slot--menu"
147
- data-chats--thread-target="popupMenu"
148
- data-action="click->chats--thread#popupMenuClicked"></div>
149
- </div>
150
- </div>
151
-
152
- <div class="chats-attachment-preview"
153
- role="dialog"
154
- aria-modal="true"
155
- tabindex="-1"
156
- data-chats--thread-target="attachmentDialog"
157
- data-action="keydown.esc->chats--thread#closeAttachment"
158
- hidden>
159
- <div class="chats-attachment-preview__surface"
160
- data-action="click->chats--thread#closeAttachmentFromBackdrop">
161
- <button type="button"
162
- class="chats-attachment-preview__close"
163
- aria-label="<%= t("chats.message.close_attachment") %>"
164
- data-action="chats--thread#closeAttachment">×</button>
165
- <figure class="chats-attachment-preview__figure">
166
- <img class="chats-attachment-preview__image"
167
- alt=""
168
- data-chats--thread-target="attachmentImage">
169
- </figure>
170
- </div>
171
- </div>
172
-
173
- <%# A locked conversation (its SUBJECT says so — Chats::ChatSubject#
174
- chat_locked?) keeps its whole history readable and swaps the composer
175
- for the reason it's closed. Never a hidden screen: gate the action,
176
- explain it in place. %>
177
- <% if @conversation.locked? %>
178
- <%= render "chats/conversations/locked_composer", conversation: @conversation %>
179
- <% else %>
180
- <%= render "chats/messages/composer", conversation: @conversation %>
181
- <% end %>
182
75
  <% end %>
@@ -0,0 +1,26 @@
1
+ <%# A floating dock window's content (GET /:id?window=1 from inside the
2
+ window's turbo frame — see chats/dock/_window for the frame and
3
+ ConversationsController#show): the SAME thread body the page renders,
4
+ rooted at .chats-thread--window, under the window's compact header.
5
+
6
+ The header's chats--dock actions reach the dock's controller because the
7
+ window lives inside the dock element. The title is a button: it
8
+ minimizes and expands the window (LinkedIn's header click), and says
9
+ which way with aria-expanded. %>
10
+ <% title = @conversation.title_for(chats_current_messager) %>
11
+ <%= turbo_frame_tag dom_id(@conversation, :window) do %>
12
+ <section class="chats-window__card" role="region" aria-label="<%= t("chats.dock.window_label", name: title) %>">
13
+ <%= render layout: "chats/conversations/thread",
14
+ locals: {
15
+ conversation: @conversation,
16
+ messages: @messages,
17
+ more: @more_messages,
18
+ first_unread_id: @first_unread_id,
19
+ counterpart: chats_counterpart,
20
+ window: true
21
+ } do %>
22
+ <%= render "chats/dock/window_header", conversation: @conversation, title: title,
23
+ counterpart: chats_counterpart %>
24
+ <% end %>
25
+ </section>
26
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <%# The dock's unread badge: the same number as the nav badge
2
+ (`unread_chats_count`), replaced on the viewer's dock stream with every
3
+ other [data-chats-unread-badge] inside the dock. No id — a page may hold
4
+ the nav badge too, and ids must stay unique. Not a live region: it would
5
+ chatter on every read; new messages are announced by the dock instead.
6
+ Hidden at zero, number kept. %>
7
+ <%= tag.span class: "chats-badge chats-dock__badge",
8
+ hidden: !count.positive?,
9
+ data: { chats_unread_badge: true, "chats--dock-target": "badge", count: count } do %>
10
+ <span aria-hidden="true"><%= count > 99 ? "99+" : count %></span>
11
+ <span class="chats-visually-hidden"><%= count %> <%= t("chats.dock.unread_suffix") %></span>
12
+ <% end %>