chats 0.5.1 → 0.6.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -0
  3. data/README.md +36 -6
  4. data/app/assets/stylesheets/chats.css +111 -0
  5. data/app/controllers/chats/conversations_controller.rb +101 -11
  6. data/app/javascript/chats/dock_controller.js +15 -1
  7. data/app/javascript/chats/split_controller.js +97 -0
  8. data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
  9. data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
  10. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  11. data/app/views/chats/conversations/_group.html.erb +20 -4
  12. data/app/views/chats/conversations/_thread.html.erb +22 -8
  13. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  14. data/app/views/chats/conversations/index.html.erb +77 -14
  15. data/app/views/chats/conversations/pane.html.erb +12 -0
  16. data/app/views/chats/conversations/show.html.erb +10 -65
  17. data/app/views/chats/dock/_dock.html.erb +2 -1
  18. data/chats-0.3.2.gem +0 -0
  19. data/chats-0.4.0.gem +0 -0
  20. data/chats-0.4.1.gem +0 -0
  21. data/chats-0.4.2.gem +0 -0
  22. data/chats-0.5.0.gem +0 -0
  23. data/chats-0.5.1.gem +0 -0
  24. data/config/importmap.rb +1 -0
  25. data/config/locales/en.yml +1 -0
  26. data/config/locales/es.yml +1 -0
  27. data/docs/screenshots/split-inbox-empty.png +0 -0
  28. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  29. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  30. data/docs/screenshots/split-inbox.png +0 -0
  31. data/lib/chats/configuration.rb +23 -0
  32. data/lib/chats/version.rb +1 -1
  33. data/lib/generators/chats/templates/initializer.rb +9 -0
  34. metadata +16 -1
@@ -0,0 +1,97 @@
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
+ // When the pane opens a conversation, its row becomes the current one
26
+ // (aria-current="page", .chats-row--current) at once, exactly as the server
27
+ // renders /messages/:id, instead of when the list next refreshes. And the
28
+ // focus goes where the viewer is now, as LinkedIn's messaging page does:
29
+ // into the conversation's composer, ready to reply. Entering the pane, a
30
+ // screen reader names it (a section labelled by the conversation's title),
31
+ // then the field. On a touch screen the composer would raise the keyboard
32
+ // over a conversation not read yet, so the focus goes to the pane's title
33
+ // instead (tabindex="-1"), which is read out the same; and to the title
34
+ // too when there is nothing to write in (a locked conversation).
35
+ //
36
+ // Registered as "chats--split" through the engine's importmap pin.
37
+ const WIDE = "(min-width: 700px)" // keep in step with chats.css
38
+ const TOUCH = "(pointer: coarse)"
39
+
40
+ export default class extends Controller {
41
+ static targets = ["frame", "list"]
42
+
43
+ connect() {
44
+ this.media = window.matchMedia?.(WIDE)
45
+ this.media?.addEventListener?.("change", this.layout)
46
+ this.layout()
47
+ }
48
+
49
+ disconnect() {
50
+ this.media?.removeEventListener?.("change", this.layout)
51
+ }
52
+
53
+ layout = () => {
54
+ if (this.hasFrameTarget) this.frameTarget.toggleAttribute("disabled", !this.media?.matches)
55
+ }
56
+
57
+ // turbo:before-morph-attribute: a refresh leaves the frame as the screen has it.
58
+ keepEnabled(event) {
59
+ if (event.target === this.frameTarget && event.detail?.attributeName === "disabled") event.preventDefault()
60
+ }
61
+
62
+ // turbo:before-prefetch: nothing names a frame that can't take it.
63
+ prefetch(event) {
64
+ if (!this.hasFrameTarget || !this.frameTarget.hasAttribute("disabled")) return
65
+ if (event.target?.getAttribute?.("data-turbo-frame") === this.frameTarget.id) event.preventDefault()
66
+ }
67
+
68
+ // turbo:frame-load: the pane opened a conversation. Its row is current,
69
+ // and the viewer is taken to it.
70
+ opened(event) {
71
+ if (!this.hasFrameTarget || event.target !== this.frameTarget || !this.hasListTarget) return
72
+ const src = this.frameTarget.getAttribute("src")
73
+ if (!src) return
74
+
75
+ const open = new URL(src, document.baseURI).pathname
76
+ this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
77
+ const current = new URL(link.getAttribute("href"), document.baseURI).pathname === open
78
+ if (current) {
79
+ link.setAttribute("aria-current", "page")
80
+ } else {
81
+ link.removeAttribute("aria-current")
82
+ }
83
+ link.closest(".chats-row")?.classList.toggle("chats-row--current", current)
84
+ })
85
+ this.arrive()
86
+ }
87
+
88
+ arrive() {
89
+ const pane = this.frameTarget.querySelector(".chats-split__pane")
90
+ if (!pane) return
91
+
92
+ const touch = Boolean(window.matchMedia?.(TOUCH)?.matches)
93
+ const composer = touch ? null : pane.querySelector(".chats-composer__input")
94
+ const title = pane.querySelector(`[id="${pane.getAttribute("aria-labelledby")}"]`)
95
+ ;(composer || title)?.focus({ preventScroll: true })
96
+ }
97
+ }
@@ -0,0 +1,42 @@
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
+ Locals: conversation, participant, messages, more, first_unread_id,
21
+ counterpart, read_pending (as in chats/conversations/_thread). %>
22
+ <% title_id = dom_id(conversation, :title) %>
23
+ <%= tag.section class: "chats-split__pane", "aria-labelledby": title_id,
24
+ data: { conversation_id: conversation.id } do %>
25
+ <%= render "chats/conversations/thread_header",
26
+ conversation: conversation, participant: participant, counterpart: counterpart,
27
+ heading: :h2, title_id: title_id, back_path: conversations_path(**chats_inbox_filter) %>
28
+
29
+ <%= tag.div id: "chats_conversation_pane_#{conversation.id}_#{Chats.messager_key(chats_current_messager)}",
30
+ class: "chats-split__thread", data: { turbo_permanent: true } do %>
31
+ <%= render layout: "chats/conversations/thread",
32
+ locals: {
33
+ conversation: conversation,
34
+ messages: messages,
35
+ more: more,
36
+ first_unread_id: first_unread_id,
37
+ counterpart: counterpart,
38
+ pane: true,
39
+ read_pending: read_pending
40
+ } do %><% end %>
41
+ <% end %>
42
+ <% 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>
@@ -3,6 +3,25 @@
3
3
  re-renders this page per-viewer — see Chats::Broadcasts for why refreshes
4
4
  beat surgical row patches here.
5
5
 
6
+ Two panes (`config.inbox_layout = :split`, the default): the list in a
7
+ <nav> on the left, and on the right the `chats_conversation` frame with
8
+ the open conversation (/messages/:id renders this same template, see
9
+ ConversationsController#show) or the empty pane (/messages). chats.css
10
+ decides with one media query: at least 700px wide both panes show; below
11
+ that the page is the list (/messages) or the thread (/messages/:id), as
12
+ with `:stacked`, which renders the list alone and the thread on its own
13
+ page (show.html.erb). Every request renders the same markup; only CSS
14
+ knows the width, so it works before JavaScript runs and without it.
15
+
16
+ A row is a real link to /messages/:id. On a wide screen it targets the
17
+ frame: the server answers with the pane alone (pane.html.erb) and the
18
+ frame's data-turbo-action="advance" moves the address bar and history
19
+ there. A reload, a deep link or back/forward renders the whole page.
20
+ The frame is `disabled` until chats--split enables it, and only while
21
+ the screen is wide: on a phone, and without JavaScript, the same link
22
+ opens the thread page. The pane keeps its draft, scroll and stream
23
+ through every refresh of the list (see _conversation_pane).
24
+
6
25
  Rows come from Chats::Inbox and are either a Chats::Conversation or a
7
26
  Chats::InboxGroup (every direct thread with a `inbox: :grouped` messager,
8
27
  stacked into one row).
@@ -39,8 +58,9 @@
39
58
  <%= turbo_stream_from chats_current_messager, :chats_inbox %>
40
59
  </div>
41
60
 
42
- <%# data-chats-dock-hide: the messaging dock hides itself on this page. %>
43
- <div class="chats chats-inbox" data-chats-dock-hide>
61
+ <% split = chats_two_panes? %>
62
+ <% inbox_empty = @rows.none? && params[:q].blank? %>
63
+ <% list = capture do %>
44
64
  <header class="chats-inbox__header">
45
65
  <h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
46
66
  </header>
@@ -53,15 +73,17 @@
53
73
  <% end %>
54
74
 
55
75
  <% if Chats.config.search && !@inbox.filtered? %>
56
- <%# The inbox's own path exactly as it was opened (/messages or
57
- /messages/: the engine answers both), so the URL a search puts in the
58
- address bar is still this page to Turbo, and the next refresh morphs
59
- it instead of re-rendering it from scratch. %>
60
- <%= form_with url: request.original_fullpath.split("?").first,
61
- method: :get,
76
+ <%# No `action` (so a bare tag.form: form_with always writes one): a form
77
+ without one submits to the page's own URL (the HTML standard, and
78
+ Turbo too), whatever that is NOW — /messages,
79
+ /messages/ (the engine answers both), or /messages/:id after a row
80
+ opened a conversation in the pane and moved the address bar. So the
81
+ URL a search puts in the address bar is still this page to Turbo,
82
+ the next refresh morphs it instead of re-rendering it from scratch,
83
+ and on the two-pane page the open conversation stays open. %>
84
+ <%= tag.form method: :get,
62
85
  class: "chats-search",
63
- # `html:`: form_with drops attributes it doesn't know (role among them).
64
- html: { role: "search" },
86
+ role: "search",
65
87
  data: {
66
88
  controller: "chats--debounced-submit",
67
89
  action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
@@ -88,12 +110,17 @@
88
110
  <ul class="chats-inbox__list">
89
111
  <% @rows.each do |row| %>
90
112
  <% if row.is_a?(Chats::InboxGroup) %>
91
- <%= render "chats/conversations/group", group: row, viewer: chats_current_messager %>
113
+ <%= render "chats/conversations/group", group: row, viewer: chats_current_messager,
114
+ pane: split, current: @conversation,
115
+ filter: chats_inbox_filter %>
92
116
  <% else %>
93
117
  <%= render "chats/conversations/conversation_row",
94
118
  conversation: row,
95
119
  viewer: chats_current_messager,
96
- unread_count: @unread_counts.fetch(row.id, 0) %>
120
+ unread_count: @unread_counts.fetch(row.id, 0),
121
+ pane: split,
122
+ current: @conversation,
123
+ filter: chats_inbox_filter %>
97
124
  <% end %>
98
125
  <% end %>
99
126
  </ul>
@@ -102,7 +129,8 @@
102
129
  <p class="chats-empty__title"><%= t("chats.inbox.no_results_title") %></p>
103
130
  <p class="chats-empty__hint"><%= t("chats.inbox.no_results_hint", query: params[:q]) %></p>
104
131
  </div>
105
- <% else %>
132
+ <% elsif !split %>
133
+ <%# (Two panes: the empty pane says it, once — chats/conversations/_empty_pane.) %>
106
134
  <div class="chats-empty">
107
135
  <span class="chats-empty__icon" aria-hidden="true">💬</span>
108
136
  <p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
@@ -111,4 +139,39 @@
111
139
  </div>
112
140
  <% end %>
113
141
  <% end %>
114
- </div>
142
+ <% end %>
143
+
144
+ <%# data-chats-dock-hide: the messaging dock hides itself on this page. %>
145
+ <% if split %>
146
+ <%= tag.div class: "chats chats-inbox chats-split",
147
+ data: {
148
+ chats_dock_hide: true,
149
+ controller: "chats--split",
150
+ action: "turbo:before-prefetch->chats--split#prefetch " \
151
+ "turbo:frame-load->chats--split#opened " \
152
+ "turbo:before-morph-attribute->chats--split#keepEnabled"
153
+ } do %>
154
+ <nav class="chats-split__list" aria-label="<%= t("chats.inbox.title") %>" data-chats--split-target="list">
155
+ <%= list %>
156
+ </nav>
157
+
158
+ <%= turbo_frame_tag Chats::ConversationsController::PANE_FRAME,
159
+ class: "chats-split__conversation",
160
+ target: "_top",
161
+ disabled: true,
162
+ data: { turbo_action: "advance", "chats--split-target": "frame" } do %>
163
+ <% if @conversation %>
164
+ <%= render "chats/conversations/conversation_pane",
165
+ conversation: @conversation, participant: @participant,
166
+ messages: @messages, more: @more_messages, first_unread_id: @first_unread_id,
167
+ counterpart: chats_counterpart, read_pending: @read_pending %>
168
+ <% else %>
169
+ <%= render "chats/conversations/empty_pane", inbox_empty: inbox_empty, inbox: @inbox %>
170
+ <% end %>
171
+ <% end %>
172
+ <% end %>
173
+ <% else %>
174
+ <div class="chats chats-inbox" data-chats-dock-hide>
175
+ <%= list %>
176
+ </div>
177
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <%# The answer to a row click on the two-pane messaging page: the request
2
+ comes from the page's `chats_conversation` frame (Turbo-Frame header),
3
+ so the answer is that frame with the conversation pane in it and
4
+ nothing else — no list, no layout (ConversationsController#show).
5
+ Turbo swaps it into the frame and, the frame being
6
+ data-turbo-action="advance", moves the address bar to /messages/:id. %>
7
+ <%= turbo_frame_tag Chats::ConversationsController::PANE_FRAME do %>
8
+ <%= render "chats/conversations/conversation_pane",
9
+ conversation: @conversation, participant: @participant,
10
+ messages: @messages, more: @more_messages, first_unread_id: @first_unread_id,
11
+ counterpart: chats_counterpart, read_pending: @read_pending %>
12
+ <% end %>
@@ -1,6 +1,9 @@
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). %>
1
+ <%# The thread page under `config.inbox_layout = :stacked`: its header
2
+ (chats/conversations/_thread_header) over the shared thread body
3
+ (chats/conversations/_thread, which a floating dock window and the
4
+ two-pane page's conversation pane render too — see that partial for how
5
+ the live thread works). Under the default `:split`, /messages/:id is
6
+ the two-pane page instead (index.html.erb). %>
4
7
  <%= chats_styles %>
5
8
 
6
9
  <%= render layout: "chats/conversations/thread",
@@ -9,67 +12,9 @@
9
12
  messages: @messages,
10
13
  more: @more_messages,
11
14
  first_unread_id: @first_unread_id,
12
- counterpart: chats_counterpart
15
+ counterpart: chats_counterpart,
16
+ read_pending: @read_pending
13
17
  } do %>
14
- <header class="chats-thread__header">
15
- <%= link_to root_path, class: "chats-thread__back", "aria-label": t("chats.thread.back") do %>
16
- <span aria-hidden="true">‹</span>
17
- <% end %>
18
-
19
- <%# The counterpart is already resolved for the title, the profile link
20
- and the stack link — reuse it here instead of asking again. %>
21
- <% if chats_counterpart %>
22
- <%= chats_messager_avatar(chats_counterpart) %>
23
- <% else %>
24
- <%= chats_conversation_avatar(@conversation, chats_current_messager) %>
25
- <% end %>
26
-
27
- <div class="chats-thread__identity">
28
- <%# Both arms wrap the name in .chats-thread__name: the <h1> is a flex
29
- row (the badge is its second child), and only a real element can
30
- carry the ellipsis. %>
31
- <h1 class="chats-thread__title">
32
- <% if chats_counterpart %>
33
- <%= chats_messager_name(chats_counterpart, css_class: "chats-thread__name") %>
34
- <%= chats_verified_badge(chats_counterpart) %>
35
- <% else %>
36
- <span class="chats-thread__name"><%= @conversation.title_for(chats_current_messager) %></span>
37
- <% end %>
38
- </h1>
39
- <% if @conversation.subject_label %>
40
- <p class="chats-thread__subject"><%= @conversation.subject_label %></p>
41
- <% end %>
42
- <%# A stacked counterpart's thread is one of many: offer the way back
43
- to the whole stack (chats' filtered inbox, or the host's own
44
- screen via `group_path:`). %>
45
- <% if Chats.grouped_inbox?(chats_counterpart) %>
46
- <p class="chats-thread__see-all">
47
- <%= link_to t("chats.thread.see_all"), chats_group_path_for(chats_counterpart), class: "chats-thread__see-all-link" %>
48
- </p>
49
- <% end %>
50
- </div>
51
-
52
- <details class="chats-menu">
53
- <summary class="chats-menu__trigger" aria-label="<%= t("chats.thread.options") %>">⋯</summary>
54
- <div class="chats-menu__panel">
55
- <% if @participant&.muted? %>
56
- <%= button_to t("chats.thread.unmute"), unmute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
57
- <% else %>
58
- <%= button_to t("chats.thread.mute"), mute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
59
- <% end %>
60
- <% if @conversation.group? %>
61
- <%= button_to t("chats.thread.leave"), leave_conversation_path(@conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
62
- <% end %>
63
- <%# Slot: the host's own thread actions (block, report, archive…).
64
- `blockable` is false for messagers declared
65
- `acts_as_messager blockable: false`, so safety affordances
66
- disappear against a support desk without any class check. %>
67
- <%= chats_slot :conversation_header_actions,
68
- conversation: @conversation,
69
- viewer: chats_current_messager,
70
- counterpart: chats_counterpart,
71
- blockable: chats_blockable?(chats_counterpart) %>
72
- </div>
73
- </details>
74
- </header>
18
+ <%= render "chats/conversations/thread_header",
19
+ conversation: @conversation, participant: @participant, counterpart: chats_counterpart %>
75
20
  <% end %>
@@ -49,7 +49,8 @@
49
49
  "input->chats--dock#typed " \
50
50
  "click@document->chats--dock#delegate " \
51
51
  "turbo:before-stream-render@document->chats--dock#beforeStreamRender " \
52
- "turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged"
52
+ "turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged " \
53
+ "turbo:frame-load@document->chats--dock#pageFrameLoaded"
53
54
  } do %>
54
55
  <%= turbo_stream_from viewer, :chats_dock %>
55
56
  <%= render "chats/dock/signal" %>
data/chats-0.3.2.gem ADDED
Binary file
data/chats-0.4.0.gem ADDED
Binary file
data/chats-0.4.1.gem ADDED
Binary file
data/chats-0.4.2.gem ADDED
Binary file
data/chats-0.5.0.gem ADDED
Binary file
data/chats-0.5.1.gem ADDED
Binary file
data/config/importmap.rb CHANGED
@@ -17,6 +17,7 @@ pin "controllers/chats/debounced_submit_controller", to: "chats/debounced_submit
17
17
  pin "controllers/chats/refresh_inbox_controller", to: "chats/refresh_inbox_controller.js"
18
18
  pin "controllers/chats/dock_controller", to: "chats/dock_controller.js"
19
19
  pin "controllers/chats/unread_badge_controller", to: "chats/unread_badge_controller.js"
20
+ pin "controllers/chats/split_controller", to: "chats/split_controller.js"
20
21
 
21
22
  # Not a controller: what the inbox, thread, badge and dock controllers share
22
23
  # to catch up on broadcasts they missed. Outside controllers/ so the eager