chats 0.5.1 → 0.6.2

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +171 -0
  3. data/README.md +40 -7
  4. data/app/assets/stylesheets/chats.css +131 -6
  5. data/app/controllers/chats/conversations_controller.rb +106 -11
  6. data/app/helpers/chats/engine_helper.rb +15 -0
  7. data/app/javascript/chats/dock_controller.js +15 -1
  8. data/app/javascript/chats/split_controller.js +131 -0
  9. data/app/javascript/chats/thread_controller.js +5 -0
  10. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  11. data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
  12. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  13. data/app/views/chats/conversations/_group.html.erb +20 -4
  14. data/app/views/chats/conversations/_thread.html.erb +22 -8
  15. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  16. data/app/views/chats/conversations/index.html.erb +81 -14
  17. data/app/views/chats/conversations/pane.html.erb +14 -0
  18. data/app/views/chats/conversations/show.html.erb +11 -65
  19. data/app/views/chats/dock/_dock.html.erb +2 -1
  20. data/config/importmap.rb +1 -0
  21. data/config/locales/en.yml +4 -0
  22. data/config/locales/es.yml +4 -0
  23. data/docs/screenshots/split-inbox-empty.png +0 -0
  24. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  25. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  26. data/docs/screenshots/split-inbox.png +0 -0
  27. data/lib/chats/configuration.rb +36 -0
  28. data/lib/chats/models/conversation.rb +10 -2
  29. data/lib/chats/models/message.rb +19 -3
  30. data/lib/chats/version.rb +1 -1
  31. data/lib/generators/chats/templates/initializer.rb +17 -0
  32. metadata +11 -3
  33. data/chats-0.3.1.gem +0 -0
@@ -14,9 +14,12 @@ module Chats
14
14
  # 36): a client past either gets its history re-rendered instead.
15
15
  CURSOR_IDS_LIMIT = 200
16
16
  CURSOR_ID_LENGTH = 64
17
+ # The two-pane messaging page's conversation frame (index.html.erb): a
18
+ # request from it is answered with the pane alone (pane.html.erb).
19
+ PANE_FRAME = "chats_conversation"
17
20
 
18
21
  before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
19
- helper_method :chats_counterpart
22
+ helper_method :chats_counterpart, :chats_two_panes?, :chats_inbox_filter
20
23
 
21
24
  # The inbox. Everything is preloaded/batched so rendering N rows costs a
22
25
  # constant number of queries (conversations + last messages + participants
@@ -29,14 +32,7 @@ module Chats
29
32
  # is the dock's own list.
30
33
  return dock if turbo_frame_request_id == "chats_dock_list"
31
34
 
32
- @inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
33
- @rows = @inbox.rows
34
- @unread_counts = @inbox.unread_counts
35
- # Back-compat for inboxes ejected under 0.1.x, which loop over
36
- # @conversations: they keep rendering the flat list (no stacking, i.e.
37
- # exactly what they rendered before). Re-eject or delete your copy to
38
- # get the stacked rows.
39
- @conversations = @inbox.conversations
35
+ load_inbox
40
36
  end
41
37
 
42
38
  # The messaging dock's recent list (`chats_dock`): the first rows of the
@@ -67,6 +63,12 @@ module Chats
67
63
  # `?window=1` from a Turbo frame is the same thread as a floating dock
68
64
  # window: the window template (its compact header around the same
69
65
  # thread body), no layout.
66
+ #
67
+ # With two panes (`config.inbox_layout = :split`, the default) the
68
+ # thread is the conversation pane of the messaging page: the whole page
69
+ # (the inbox's template, with the pane open), or, asked from that page's
70
+ # own conversation frame (a row clicked on a wide screen), the pane
71
+ # alone. `:stacked` renders the thread page.
70
72
  def show
71
73
  @participant = @conversation.participant_for(chats_current_messager)
72
74
 
@@ -90,10 +92,14 @@ module Chats
90
92
  # swallow the unread badge. The thread controller posts #read once
91
93
  # the window is actually on screen (`read-pending`).
92
94
  render :window, layout: false
93
- elsif !prefetch?
95
+ else
94
96
  # Opening the thread reads it. (Live appends while the thread
95
97
  # stays open are read via the thread controller's POST to #read.)
96
- @participant&.read!
98
+ # A render the server doesn't read is marked `read-pending`: the
99
+ # thread controller reads it once it is on screen.
100
+ @read_pending = !reads_on_render?
101
+ @participant&.read! unless @read_pending
102
+ render_split_page if chats_two_panes?
97
103
  end
98
104
  end
99
105
  end
@@ -226,6 +232,95 @@ module Chats
226
232
  @conversation = find_conversation
227
233
  end
228
234
 
235
+ # The inbox's rows, for the inbox page and for the two-pane page.
236
+ def load_inbox
237
+ @inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
238
+ @rows = @inbox.rows
239
+ @unread_counts = @inbox.unread_counts
240
+ # Back-compat for inboxes ejected under 0.1.x, which loop over
241
+ # @conversations: they keep rendering the flat list (no stacking, i.e.
242
+ # exactly what they rendered before). Re-eject or delete your copy to
243
+ # get the stacked rows.
244
+ @conversations = @inbox.conversations
245
+ end
246
+
247
+ # The open conversation on the two-pane page: the pane alone for the
248
+ # page's own conversation frame, else the whole page around it (any
249
+ # other frame of the page, the search's results among them, finds its
250
+ # own part in it).
251
+ #
252
+ # The pane alone comes in turbo-rails' minimal frame layout, always (even
253
+ # under a `config.layout`): nothing of the host's page is rendered for
254
+ # Turbo to throw away. Its title travels on the pane (`data-title`,
255
+ # chats_page_title), since Turbo leaves <head> alone on a frame visit.
256
+ def render_split_page
257
+ return render :pane, layout: "turbo_rails/frame" if pane_request?
258
+
259
+ load_inbox
260
+ render :index
261
+ end
262
+
263
+ def pane_request?
264
+ turbo_frame_request_id == PANE_FRAME
265
+ end
266
+
267
+ # Whether the messaging page is two panes: `config.inbox_layout = :split`
268
+ # (the default), unless this app ejected its views before there were
269
+ # two panes. `rails g chats:views` under 0.5 copied an inbox and a
270
+ # thread page and no conversation pane: that app keeps its own separate
271
+ # pages, rendered and read exactly as before, until it ejects again (or
272
+ # deletes its copies). Views ejected since 0.6 carry the pane, and are
273
+ # two panes like the gem's own.
274
+ def chats_two_panes?
275
+ return @chats_two_panes if defined?(@chats_two_panes)
276
+
277
+ @chats_two_panes = Chats.config.split_inbox? && !views_ejected_before_two_panes?
278
+ end
279
+
280
+ # The list's filter on the two-pane page: its search (`q`) or the stack
281
+ # it shows (`with`). A row opened in the pane carries it into its URL,
282
+ # /messages/:id?with=…, because that URL is what the list's live
283
+ # refreshes re-request: without it, the next refresh would bring back
284
+ # the whole inbox under a list that was filtered. The back link to the
285
+ # list keeps it too.
286
+ def chats_inbox_filter
287
+ { q: params[:q], with: params[:with] }.select { |_, value| value.is_a?(String) && value.present? }
288
+ end
289
+
290
+ def views_ejected_before_two_panes?
291
+ (own_view?("index") || own_view?("show")) && !own_view?("conversation_pane", partial: true)
292
+ end
293
+
294
+ # Whether the app renders its own copy of one of these views, not the gem's.
295
+ def own_view?(name, partial: false)
296
+ template = lookup_context.find_all(name, ["chats/conversations"], partial).first
297
+ template.present? && !template.identifier.to_s.start_with?(Chats::Engine.root.join("app/views").to_s)
298
+ end
299
+
300
+ # Whether showing the conversation reads it, there and then.
301
+ #
302
+ # Never a prefetch (Turbo 8 fetches a link the pointer rests on, which
303
+ # is nobody reading). On the thread page, everything else: opening it
304
+ # reads it.
305
+ #
306
+ # On the two-pane page, the viewer opening it: a row clicked (the
307
+ # pane's frame) or the page loaded by the browser itself (a link from
308
+ # elsewhere, a reload, no JavaScript). NOT a Turbo visit of the page,
309
+ # because one of those is the inbox's own refresh: every live update of
310
+ # the list re-requests this page, maybe in a hidden tab, and a read
311
+ # there would say "Seen" to the sender while nobody looks — and, since
312
+ # a read refreshes the reader's inbox, refresh the page again, forever.
313
+ # Turbo marks each of its requests (X-Turbo-Request-Id) and a refresh
314
+ # looks like any other visit, so a Turbo visit renders the pane unread
315
+ # and its thread reads it once it's on screen. A refresh's pane never
316
+ # gets that far: the page keeps its permanent one, and drops the new.
317
+ def reads_on_render?
318
+ return false if prefetch?
319
+ return true unless chats_two_panes?
320
+
321
+ pane_request? || request.headers["X-Turbo-Request-Id"].blank?
322
+ end
323
+
229
324
  def time_from_ms(value)
230
325
  Time.zone.at(0, value.to_i, :millisecond)
231
326
  end
@@ -329,6 +329,21 @@ module Chats
329
329
  path.presence || chats_routes.conversations_path(with: Chats.inbox_with_sgid(messager))
330
330
  end
331
331
 
332
+ # The messaging page's title: the inbox's, or +conversation+'s.
333
+ # `config.page_title` when the host sets one, else the localized
334
+ # `chats.page_title.*`. ONE source for both ways a conversation opens:
335
+ # the bundled pages hand it to `content_for :title` on a full visit, and
336
+ # the two-pane page's pane carries it (`data-title`) for chats--split to
337
+ # give the tab when a row opens it, since Turbo leaves <head> alone then.
338
+ def chats_page_title(conversation = nil)
339
+ viewer = chats_current_messager
340
+ custom = Chats.config.page_title&.call(conversation, viewer)
341
+ return custom.to_s if custom.present?
342
+ return t("chats.page_title.inbox") unless conversation
343
+
344
+ t("chats.page_title.conversation", name: conversation.title_for(viewer))
345
+ end
346
+
332
347
  # The gem's bundled stylesheet (CSS-variable themed — see chats.css).
333
348
  # Called from the engine's own views; hosts that eject + restyle the
334
349
  # views with their own framework simply don't include it.
@@ -44,7 +44,9 @@ import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
44
44
  // Every access is guarded: no storage, no memory, no harm.
45
45
  // * hiding on any page with [data-chats-dock-hide] (the engine's
46
46
  // inbox and thread), since a permanent element is never
47
- // re-rendered by the server
47
+ // re-rendered by the server; and no window for a
48
+ // conversation the page renders, after a visit or after a
49
+ // frame of the page loads one
48
50
  // * keyboard Escape unwinds innermost first: the thread's popup or
49
51
  // photo, an edit, the window menu, then the window
50
52
  // (minimized), the panel (closed); on a phone, the window
@@ -782,6 +784,18 @@ export default class extends Controller {
782
784
  this.save()
783
785
  }
784
786
 
787
+ // A frame of the page itself loaded a conversation (the two-pane messaging
788
+ // page opens one beside its list without a visit): no window keeps a
789
+ // second copy of it.
790
+ pageFrameLoaded(event) {
791
+ if (this.element.contains(event.target)) return
792
+
793
+ this.windowTargets.forEach((frame) => {
794
+ if (this.renderedOnPage(frame.dataset.conversationId)) this.removeWindow(frame)
795
+ })
796
+ this.save()
797
+ }
798
+
785
799
  // A row whose conversation is open in a window says so.
786
800
  markOpenRows() {
787
801
  if (!this.hasListTarget) return
@@ -0,0 +1,131 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // chats--split — the two-pane messaging page (`config.inbox_layout = :split`).
4
+ //
5
+ // chats.css lays the page out on its own, with one media query: both panes
6
+ // from 700px wide, one below. What CSS can't decide is where a row's link
7
+ // goes, and that is all this does:
8
+ //
9
+ // * wide: the conversation frame is enabled, so a row opens its
10
+ // conversation beside the list, in one request for the pane alone, and
11
+ // the address bar moves to /messages/:id (the frame's
12
+ // data-turbo-action="advance");
13
+ // * narrow: the frame stays `disabled`, so the same link opens the
14
+ // conversation's page, which then fills the screen. A disabled frame is
15
+ // one Turbo never navigates, links naming it included. And no hover
16
+ // prefetch: the link names the frame, so Turbo would ask for the pane
17
+ // alone, and the page visit would then render that answer as the page.
18
+ //
19
+ // The server always renders the frame disabled, because a page visit is
20
+ // right at any width: it is what a click does before this connects, and
21
+ // without JavaScript. So the page's refreshes (Turbo 8 morphs, every live
22
+ // update of the list) would disable it again on a wide screen; the frame
23
+ // keeps what the screen says.
24
+ //
25
+ // The tab's title follows the pane: Turbo leaves <head> alone when it
26
+ // promotes a frame load to a visit, so the title would stay the previous
27
+ // page's. The pane carries the page's title with it open (`data-title`,
28
+ // chats_page_title: the same the full page gives `content_for :title`).
29
+ //
30
+ // The open conversation's row stops counting it unread as soon as it is
31
+ // read (the pane's answer says the server read it, `data-read`; or its
32
+ // thread reads it on screen, `chats:read`), without waiting for the list's
33
+ // live refresh, which may be seconds away or not come at all.
34
+ //
35
+ // When the pane opens a conversation, its row becomes the current one
36
+ // (aria-current="page", .chats-row--current) at once, exactly as the server
37
+ // renders /messages/:id, instead of when the list next refreshes. And the
38
+ // focus goes where the viewer is now, as LinkedIn's messaging page does:
39
+ // into the conversation's composer, ready to reply. Entering the pane, a
40
+ // screen reader names it (a section labelled by the conversation's title),
41
+ // then the field. On a touch screen the composer would raise the keyboard
42
+ // over a conversation not read yet, so the focus goes to the pane's title
43
+ // instead (tabindex="-1"), which is read out the same; and to the title
44
+ // too when there is nothing to write in (a locked conversation).
45
+ //
46
+ // Registered as "chats--split" through the engine's importmap pin.
47
+ const WIDE = "(min-width: 700px)" // keep in step with chats.css
48
+ const TOUCH = "(pointer: coarse)"
49
+
50
+ export default class extends Controller {
51
+ static targets = ["frame", "list"]
52
+
53
+ connect() {
54
+ this.media = window.matchMedia?.(WIDE)
55
+ this.media?.addEventListener?.("change", this.layout)
56
+ this.layout()
57
+ }
58
+
59
+ disconnect() {
60
+ this.media?.removeEventListener?.("change", this.layout)
61
+ }
62
+
63
+ layout = () => {
64
+ if (this.hasFrameTarget) this.frameTarget.toggleAttribute("disabled", !this.media?.matches)
65
+ }
66
+
67
+ // turbo:before-morph-attribute: a refresh leaves the frame as the screen has it.
68
+ keepEnabled(event) {
69
+ if (event.target === this.frameTarget && event.detail?.attributeName === "disabled") event.preventDefault()
70
+ }
71
+
72
+ // turbo:before-prefetch: nothing names a frame that can't take it.
73
+ prefetch(event) {
74
+ if (!this.hasFrameTarget || !this.frameTarget.hasAttribute("disabled")) return
75
+ if (event.target?.getAttribute?.("data-turbo-frame") === this.frameTarget.id) event.preventDefault()
76
+ }
77
+
78
+ // turbo:frame-load: the pane opened a conversation. Its row is current,
79
+ // and the viewer is taken to it.
80
+ opened(event) {
81
+ if (!this.hasFrameTarget || event.target !== this.frameTarget || !this.hasListTarget) return
82
+ const src = this.frameTarget.getAttribute("src")
83
+ if (!src) return
84
+
85
+ const open = new URL(src, document.baseURI).pathname
86
+ this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
87
+ const current = new URL(link.getAttribute("href"), document.baseURI).pathname === open
88
+ if (current) {
89
+ link.setAttribute("aria-current", "page")
90
+ } else {
91
+ link.removeAttribute("aria-current")
92
+ }
93
+ link.closest(".chats-row")?.classList.toggle("chats-row--current", current)
94
+ })
95
+ const pane = this.frameTarget.querySelector(".chats-split__pane")
96
+ if (pane?.dataset.title) document.title = pane.dataset.title
97
+ if (pane?.dataset.read === "true") this.markRead(pane)
98
+ this.arrive()
99
+ }
100
+
101
+ // chats:read, from the pane's thread.
102
+ threadRead(event) {
103
+ const pane = event.target?.closest?.(".chats-split__pane")
104
+ if (pane) this.markRead(pane)
105
+ }
106
+
107
+ // The conversation's row, read: no count, no bold, as the next refresh
108
+ // will render it.
109
+ markRead(pane) {
110
+ if (!this.hasListTarget || !pane.dataset.path) return
111
+
112
+ const path = new URL(pane.dataset.path, document.baseURI).pathname
113
+ this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
114
+ if (new URL(link.getAttribute("href"), document.baseURI).pathname !== path) return
115
+
116
+ const row = link.closest(".chats-row")
117
+ row?.classList.remove("chats-row--unread")
118
+ row?.querySelector(".chats-row__bottom .chats-badge")?.remove()
119
+ })
120
+ }
121
+
122
+ arrive() {
123
+ const pane = this.frameTarget.querySelector(".chats-split__pane")
124
+ if (!pane) return
125
+
126
+ const touch = Boolean(window.matchMedia?.(TOUCH)?.matches)
127
+ const composer = touch ? null : pane.querySelector(".chats-composer__input")
128
+ const title = pane.querySelector(`[id="${pane.getAttribute("aria-labelledby")}"]`)
129
+ ;(composer || title)?.focus({ preventScroll: true })
130
+ }
131
+ }
@@ -456,9 +456,14 @@ export default class extends Controller {
456
456
  this.pendingRead = true
457
457
  return
458
458
  }
459
+ // chats:read, once the server has it: the two-pane page clears the
460
+ // conversation's row (chats--split) without waiting for the list's
461
+ // live refresh.
459
462
  fetch(this.readUrlValue, {
460
463
  method: "POST",
461
464
  headers: { "X-CSRF-Token": csrfToken(), Accept: "application/json" }
465
+ }).then((response) => {
466
+ if (response.ok) this.element.dispatchEvent(new CustomEvent("chats:read", { bubbles: true }))
462
467
  }).catch(() => {})
463
468
  }
464
469
 
@@ -0,0 +1,53 @@
1
+ <%# The open conversation on the two-pane messaging page (`config.inbox_layout
2
+ = :split`), inside the page's `chats_conversation` frame: the header
3
+ (chats/conversations/_thread_header) over the shared thread body
4
+ (chats/conversations/_thread, the same one the thread page and a dock
5
+ window render).
6
+
7
+ The thread is wrapped in a data-turbo-permanent element keyed by the
8
+ conversation, the way the dock keys its own element: the inbox's live
9
+ updates are Turbo 8 page refreshes that MORPH this whole page, and a
10
+ morph leaves a permanent element alone, so the draft, the scroll
11
+ position, the read state and the thread's stream subscription all go
12
+ on untouched while the list beside it re-renders. Opening another
13
+ conversation renders another id, which replaces it. The id also names
14
+ the viewer: a visit that lands on another account's page (signing out
15
+ and in, an account switcher) never inherits someone else's thread.
16
+
17
+ The header stays outside it, so a refresh still brings it up to date
18
+ (a mute, a renamed group).
19
+
20
+ data-title: the page's title with this conversation open
21
+ (chats_page_title, the same the full page gives `content_for :title`):
22
+ chats--split gives it to the tab when a row opens the pane, since Turbo
23
+ leaves <head> alone on a frame visit.
24
+
25
+ data-read: the server read the conversation as it rendered this (a row
26
+ clicked); chats--split then clears its row's unread count at once,
27
+ instead of when the list next refreshes. A pane rendered unread clears
28
+ it when its thread reads (the thread's chats:read event).
29
+
30
+ Locals: conversation, participant, messages, more, first_unread_id,
31
+ counterpart, read_pending (as in chats/conversations/_thread). %>
32
+ <% title_id = dom_id(conversation, :title) %>
33
+ <%= tag.section class: "chats-split__pane", "aria-labelledby": title_id,
34
+ data: { conversation_id: conversation.id, path: conversation_path(conversation),
35
+ title: chats_page_title(conversation), read: !read_pending } do %>
36
+ <%= render "chats/conversations/thread_header",
37
+ conversation: conversation, participant: participant, counterpart: counterpart,
38
+ heading: :h2, title_id: title_id, back_path: conversations_path(**chats_inbox_filter) %>
39
+
40
+ <%= tag.div id: "chats_conversation_pane_#{conversation.id}_#{Chats.messager_key(chats_current_messager)}",
41
+ class: "chats-split__thread", data: { turbo_permanent: true } do %>
42
+ <%= render layout: "chats/conversations/thread",
43
+ locals: {
44
+ conversation: conversation,
45
+ messages: messages,
46
+ more: more,
47
+ first_unread_id: first_unread_id,
48
+ counterpart: counterpart,
49
+ pane: true,
50
+ read_pending: read_pending
51
+ } do %><% end %>
52
+ <% end %>
53
+ <% end %>
@@ -5,10 +5,26 @@
5
5
  `dock: true` (the messaging dock's recent list) keeps the link a link to
6
6
  the thread and marks it as a dock opener: a plain click opens the
7
7
  conversation's floating window instead. A muted conversation carries
8
- the bell-slash. %>
9
- <li class="chats-row <%= "chats-row--unread" if unread_count.positive? %>">
10
- <%= link_to conversation_path(conversation), class: "chats-row__link",
11
- data: (chats_dock_opener(conversation) if local_assigns[:dock]) do %>
8
+ the bell-slash.
9
+
10
+ `pane: true` (the two-pane messaging page) points the link at the
11
+ page's conversation frame, which opens it beside the list on a wide
12
+ screen; on a phone, and without JavaScript, the frame is disabled and
13
+ the link opens the thread page. `current:` is the conversation the page
14
+ has open: its row says so (aria-current="page", .chats-row--current).
15
+ `filter:` is the list's search or stack (`q`, `with`), kept in the
16
+ link: the list's refreshes re-request the page's URL, and must find the
17
+ same list there. %>
18
+ <% current = local_assigns[:current] == conversation %>
19
+ <% filter = local_assigns[:pane] ? local_assigns.fetch(:filter, {}) : {} %>
20
+ <li class="<%= class_names("chats-row", "chats-row--unread": unread_count.positive?, "chats-row--current": current) %>">
21
+ <%= link_to conversation_path(conversation, **filter), class: "chats-row__link",
22
+ "aria-current": ("page" if current),
23
+ data: (if local_assigns[:dock]
24
+ chats_dock_opener(conversation)
25
+ elsif local_assigns[:pane]
26
+ { turbo_frame: Chats::ConversationsController::PANE_FRAME }
27
+ end) do %>
12
28
  <%= chats_conversation_avatar(conversation, viewer) %>
13
29
 
14
30
  <span class="chats-row__body">
@@ -0,0 +1,23 @@
1
+ <%# The conversation pane of the two-pane messaging page with no
2
+ conversation open (/messages, `config.inbox_layout = :split`).
3
+
4
+ With conversations to choose from, it asks for one. With none at all,
5
+ the inbox's own empty state lives here instead, the host's
6
+ inbox_empty slot included: rendered once on the page, never once in the
7
+ list and again beside it. On narrow screens, where the list is the
8
+ page, chats.css shows this pane under the list only in that case.
9
+
10
+ Locals: inbox_empty (no rows, no search), inbox (the Chats::Inbox). %>
11
+ <% if inbox_empty %>
12
+ <div class="chats-split__empty chats-split__empty--inbox chats-empty">
13
+ <span class="chats-empty__icon" aria-hidden="true">💬</span>
14
+ <p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
15
+ <p class="chats-empty__hint"><%= t("chats.inbox.empty_hint") %></p>
16
+ <%= chats_slot :inbox_empty, viewer: chats_current_messager, inbox: inbox %>
17
+ </div>
18
+ <% else %>
19
+ <div class="chats-split__empty chats-empty">
20
+ <span class="chats-empty__icon" aria-hidden="true">💬</span>
21
+ <p class="chats-empty__title"><%= t("chats.inbox.select_hint") %></p>
22
+ </div>
23
+ <% end %>
@@ -8,11 +8,27 @@
8
8
  either the host's `group_path:` or chats' own filtered inbox.
9
9
 
10
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. %>
12
- <li class="chats-row chats-row--group <%= "chats-row--unread" if group.unread? %>"
11
+ floating window; a deeper stack still goes to the stack list. On the
12
+ two-pane page (`pane: true`) a stack of one opens its thread in the
13
+ conversation pane, and is the current row while it is open there
14
+ (`current:`, `filter:`, as in _conversation_row); a deeper stack still
15
+ goes to the stack list. %>
16
+ <% single = group.single? %>
17
+ <% current = single && local_assigns[:current].present? && local_assigns[:current] == group.conversation %>
18
+ <li class="<%= class_names("chats-row chats-row--group", "chats-row--unread": group.unread?, "chats-row--current": current) %>"
13
19
  id="<%= group.dom_id %>">
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 %>
20
+ <% href = if local_assigns[:pane] && single
21
+ conversation_path(group.conversation, **local_assigns.fetch(:filter, {}))
22
+ else
23
+ chats_group_path(group)
24
+ end %>
25
+ <%= link_to href, class: "chats-row__link",
26
+ "aria-current": ("page" if current),
27
+ data: (if local_assigns[:dock] && single
28
+ chats_dock_opener(group.conversation)
29
+ elsif local_assigns[:pane] && single
30
+ { turbo_frame: Chats::ConversationsController::PANE_FRAME }
31
+ end) do %>
16
32
  <%= chats_messager_avatar(group.messager) %>
17
33
 
18
34
  <span class="chats-row__body">
@@ -1,9 +1,10 @@
1
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).
2
+ the thread PAGE (show.html.erb), a floating dock WINDOW (window.html.erb)
3
+ and the two-pane page's conversation PANE (_conversation_pane.html.erb).
4
+ Rendered as a partial LAYOUT — `yield` is the header, which is the one
5
+ thing they differ in: the page keeps its full header (back link, ⋯ menu),
6
+ the window its compact one (minimize, close, open full page); the pane
7
+ renders its header above the thread, outside it, and yields nothing.
7
8
 
8
9
  One conversation stream powers everything here: message
9
10
  append/replace/remove, the read-state payload, and typing pings. Bubbles
@@ -28,10 +29,19 @@
28
29
  server did NOT read this render — the controller
29
30
  posts #read once the window is on screen and the
30
31
  viewer is engaged with it (`readPending`).
32
+ pane true in the two-pane page's conversation pane: rooted
33
+ at .chats-thread--pane, inside the page's .chats shell.
34
+ read_pending the server did not read this render, so the
35
+ controller posts #read once the thread is on screen
36
+ (always true in a window; see
37
+ ConversationsController#show for when a page or a
38
+ pane is rendered unread).
31
39
 
32
40
  The page's root carries data-chats-dock-hide: the messaging dock hides
33
41
  itself on any page that has it. %>
34
42
  <% window = local_assigns.fetch(:window, false) %>
43
+ <% pane = local_assigns.fetch(:pane, false) %>
44
+ <% read_pending = window || local_assigns.fetch(:read_pending, false) %>
35
45
  <% viewer = chats_current_messager %>
36
46
  <%# A direct thread nobody may write in any more says so up front, in the
37
47
  composer's place — the host's policy turned false, or the other account
@@ -39,12 +49,16 @@
39
49
  the live lock swap still finds it. %>
40
50
  <% refused = !conversation.locked? && conversation.direct? &&
41
51
  (counterpart.nil? || !Chats.can_message?(viewer, counterpart)) %>
42
- <%= tag.div class: (window ? "chats-thread chats-thread--window" : "chats chats-thread"), data: {
52
+ <% root_class = if window then "chats-thread chats-thread--window"
53
+ elsif pane then "chats-thread chats-thread--pane"
54
+ else "chats chats-thread"
55
+ end %>
56
+ <%= tag.div class: root_class, data: {
43
57
  controller: "chats--thread",
44
- "chats-dock-hide": (true unless window),
58
+ "chats-dock-hide": (true unless window || pane), # the pane's page carries it
45
59
  "chats--thread-me-value": Chats.messager_key(viewer),
46
60
  "chats--thread-read-url-value": read_conversation_path(conversation),
47
- "chats--thread-read-pending-value": window,
61
+ "chats--thread-read-pending-value": read_pending,
48
62
  "chats--thread-sent-label-value": t("chats.thread.sent"),
49
63
  "chats--thread-seen-label-value": Chats.config.read_receipts ? t("chats.thread.seen") : "",
50
64
  "chats--thread-today-label-value": t("chats.thread.today"),
@@ -0,0 +1,83 @@
1
+ <%# The header over a conversation: the thread page's, and the conversation
2
+ pane's on the two-pane messaging page (chats/conversations/
3
+ _conversation_pane). Back to the list, who it is with (avatar, name,
4
+ the verified badge, what it is about, the way back to their stack) and
5
+ the ⋯ menu (mute, leave, the host's conversation_header_actions).
6
+
7
+ The back link always renders: on the two-pane page chats.css hides it
8
+ while the list is on screen beside the conversation.
9
+
10
+ Locals:
11
+ conversation the Chats::Conversation
12
+ participant the viewer's seat in it (mute state), or nil
13
+ counterpart the other messager of a direct thread, or nil
14
+ heading the title's element: :h1 on the thread page, :h2 in the
15
+ pane, under the page's own "Messages" heading
16
+ title_id the title's id, which a pane is labelled by (optional);
17
+ the pane's title also takes the focus when a row opens
18
+ it on a touch screen (tabindex="-1", see chats--split)
19
+ back_path where the back link goes (default: the inbox; the pane
20
+ keeps the list's search or stack) %>
21
+ <% heading = local_assigns.fetch(:heading, :h1) %>
22
+ <% title_id = local_assigns[:title_id] %>
23
+ <header class="chats-thread__header">
24
+ <%= link_to local_assigns[:back_path] || root_path, class: "chats-thread__back", "aria-label": t("chats.thread.back") do %>
25
+ <span aria-hidden="true">‹</span>
26
+ <% end %>
27
+
28
+ <%# The counterpart is already resolved for the title, the profile link
29
+ and the stack link — reuse it here instead of asking again. %>
30
+ <% if counterpart %>
31
+ <%= chats_messager_avatar(counterpart) %>
32
+ <% else %>
33
+ <%= chats_conversation_avatar(conversation, chats_current_messager) %>
34
+ <% end %>
35
+
36
+ <div class="chats-thread__identity">
37
+ <%# Both arms wrap the name in .chats-thread__name: the heading is a flex
38
+ row (the badge is its second child), and only a real element can
39
+ carry the ellipsis. %>
40
+ <%= content_tag heading, class: "chats-thread__title", id: title_id, tabindex: (-1 if title_id) do %>
41
+ <% if counterpart %>
42
+ <%= chats_messager_name(counterpart, css_class: "chats-thread__name") %>
43
+ <%= chats_verified_badge(counterpart) %>
44
+ <% else %>
45
+ <span class="chats-thread__name"><%= conversation.title_for(chats_current_messager) %></span>
46
+ <% end %>
47
+ <% end %>
48
+ <% if conversation.subject_label %>
49
+ <p class="chats-thread__subject"><%= conversation.subject_label %></p>
50
+ <% end %>
51
+ <%# A stacked counterpart's thread is one of many: offer the way back
52
+ to the whole stack (chats' filtered inbox, or the host's own
53
+ screen via `group_path:`). %>
54
+ <% if Chats.grouped_inbox?(counterpart) %>
55
+ <p class="chats-thread__see-all">
56
+ <%= link_to t("chats.thread.see_all"), chats_group_path_for(counterpart), class: "chats-thread__see-all-link" %>
57
+ </p>
58
+ <% end %>
59
+ </div>
60
+
61
+ <details class="chats-menu">
62
+ <summary class="chats-menu__trigger" aria-label="<%= t("chats.thread.options") %>">⋯</summary>
63
+ <div class="chats-menu__panel">
64
+ <% if participant&.muted? %>
65
+ <%= button_to t("chats.thread.unmute"), unmute_conversation_path(conversation), method: :post, class: "chats-menu__item" %>
66
+ <% else %>
67
+ <%= button_to t("chats.thread.mute"), mute_conversation_path(conversation), method: :post, class: "chats-menu__item" %>
68
+ <% end %>
69
+ <% if conversation.group? %>
70
+ <%= button_to t("chats.thread.leave"), leave_conversation_path(conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
71
+ <% end %>
72
+ <%# Slot: the host's own thread actions (block, report, archive…).
73
+ `blockable` is false for messagers declared
74
+ `acts_as_messager blockable: false`, so safety affordances
75
+ disappear against a support desk without any class check. %>
76
+ <%= chats_slot :conversation_header_actions,
77
+ conversation: conversation,
78
+ viewer: chats_current_messager,
79
+ counterpart: counterpart,
80
+ blockable: chats_blockable?(counterpart) %>
81
+ </div>
82
+ </details>
83
+ </header>