chats 0.5.0 → 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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +349 -0
  3. data/README.md +91 -8
  4. data/app/assets/stylesheets/chats.css +114 -0
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +305 -63
  7. data/app/helpers/chats/engine_helper.rb +27 -4
  8. data/app/javascript/chats/dock_controller.js +54 -4
  9. data/app/javascript/chats/recovery.js +539 -0
  10. data/app/javascript/chats/refresh_inbox_controller.js +55 -65
  11. data/app/javascript/chats/split_controller.js +97 -0
  12. data/app/javascript/chats/thread_controller.js +245 -54
  13. data/app/javascript/chats/unread_badge_controller.js +24 -0
  14. data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
  15. data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
  16. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  17. data/app/views/chats/conversations/_group.html.erb +20 -4
  18. data/app/views/chats/conversations/_history.html.erb +54 -0
  19. data/app/views/chats/conversations/_thread.html.erb +29 -54
  20. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  21. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  22. data/app/views/chats/conversations/index.html.erb +91 -15
  23. data/app/views/chats/conversations/pane.html.erb +12 -0
  24. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  25. data/app/views/chats/conversations/show.html.erb +10 -65
  26. data/app/views/chats/dock/_dock.html.erb +14 -5
  27. data/app/views/chats/messages/_message.html.erb +8 -6
  28. data/chats-0.3.2.gem +0 -0
  29. data/chats-0.4.0.gem +0 -0
  30. data/chats-0.4.1.gem +0 -0
  31. data/chats-0.4.2.gem +0 -0
  32. data/chats-0.5.0.gem +0 -0
  33. data/chats-0.5.1.gem +0 -0
  34. data/config/importmap.rb +7 -0
  35. data/config/locales/en.yml +1 -0
  36. data/config/locales/es.yml +1 -0
  37. data/config/routes.rb +3 -0
  38. data/docs/screenshots/split-inbox-empty.png +0 -0
  39. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  40. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  41. data/docs/screenshots/split-inbox.png +0 -0
  42. data/lib/chats/broadcasts.rb +102 -7
  43. data/lib/chats/configuration.rb +23 -0
  44. data/lib/chats/inbox_refresh_job.rb +14 -0
  45. data/lib/chats/models/conversation.rb +38 -0
  46. data/lib/chats/models/message.rb +98 -9
  47. data/lib/chats/models/reaction.rb +13 -0
  48. data/lib/chats/version.rb +1 -1
  49. data/lib/generators/chats/templates/initializer.rb +9 -0
  50. metadata +21 -1
@@ -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">
@@ -0,0 +1,54 @@
1
+ <%# The history inside a thread's scroller: the latest page of messages, the
2
+ lazy frame chain above it, the read state and the typing line. The thread
3
+ (chats/conversations/_thread) renders it once; the stale-thread catch-up
4
+ re-renders it in place when the thread fell more than a page behind, and
5
+ leaves everything else (the header, the composer and its draft) alone.
6
+
7
+ Locals: conversation, messages (oldest first), more, first_unread_id,
8
+ counterpart, window — as in chats/conversations/_thread. %>
9
+ <% viewer = chats_current_messager %>
10
+ <% if window && messages.empty? %>
11
+ <%# A window opened from a Message button, before anybody wrote: who
12
+ you're about to talk to. Gone with the first bubble (chats.css). %>
13
+ <div class="chats-window__hello">
14
+ <% if counterpart %>
15
+ <%= chats_messager_avatar(counterpart) %>
16
+ <strong><%= Chats.display_name_for(counterpart) %></strong>
17
+ <% end %>
18
+ <%= t("chats.dock.hello", name: conversation.title_for(viewer)) %>
19
+ </div>
20
+ <% end %>
21
+ <% if more && messages.any? %>
22
+ <%# Lazy frame chain for infinite scroll-up: when this frame becomes
23
+ visible, Turbo fetches the previous keyset page (?before=oldest id),
24
+ whose response contains a frame with this SAME id — older messages
25
+ plus, recursively, the next frame. See ConversationsController#show. %>
26
+ <%= turbo_frame_tag "chats_page_#{messages.first.id}",
27
+ src: conversation_path(conversation, before: messages.first.id),
28
+ loading: :lazy do %>
29
+ <div class="chats-loader"><%= t("chats.thread.loading") %></div>
30
+ <% end %>
31
+ <% end %>
32
+
33
+ <div id="<%= dom_id(conversation, :messages) %>" class="chats-thread__messages">
34
+ <%# Explicit loop (not a collection render) so the «new messages»
35
+ divider can sit BEFORE the first unread bubble (WhatsApp's "the
36
+ catch-up starts here" line). Rendered with the history only (the
37
+ thread's first render, or a catch-up that re-renders it); it scrolls
38
+ away naturally as the thread lives on. %>
39
+ <% messages.each do |message| %>
40
+ <% if message.id == first_unread_id %>
41
+ <div class="chats-thread__unread-line" role="separator">
42
+ <span><%= t("chats.thread.new_messages") %></span>
43
+ </div>
44
+ <% end %>
45
+ <%= render "chats/messages/message", message: message %>
46
+ <% end %>
47
+ </div>
48
+
49
+ <%= render "chats/conversations/read_state", conversation: conversation %>
50
+
51
+ <div id="<%= dom_id(conversation, :typing) %>"
52
+ class="chats-typing"
53
+ data-chats--thread-target="typing"
54
+ hidden></div>
@@ -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"),
@@ -67,52 +81,13 @@
67
81
 
68
82
  <%= yield %>
69
83
 
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>
84
+ <%# tabindex: the arrow keys scroll the history (WCAG 2.1.1). The id is
85
+ what the stale-thread catch-up re-renders when the thread fell more
86
+ than a page behind (ConversationsController#refresh). %>
87
+ <div id="<%= dom_id(conversation, :history) %>" class="chats-thread__scroll" data-chats--thread-target="scroller" tabindex="0">
88
+ <%= render "chats/conversations/history", conversation: conversation, messages: messages, more: more,
89
+ first_unread_id: first_unread_id, counterpart: counterpart,
90
+ window: window %>
116
91
  </div>
117
92
 
118
93
  <%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
@@ -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>
@@ -0,0 +1,6 @@
1
+ <%# Every unread badge on the page (ConversationsController#badge), as the
2
+ badge streams would have sent them: the nav badge
3
+ (Chats::Broadcasts.update_badge_of) and the dock's (chats/dock/_update).
4
+ A page without one of them simply has nothing to replace. %>
5
+ <%= turbo_stream.replace "chats_unread_badge", partial: "chats/shared/unread_badge", locals: { count: count } %>
6
+ <%= turbo_stream.replace_all ".chats-dock [data-chats-unread-badge]", partial: "chats/dock/badge", locals: { count: count } %>
@@ -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).
@@ -10,18 +29,22 @@
10
29
  The chats--refresh-inbox wrapper heals a MISSED refresh broadcast: Action
11
30
  Cable has no replay, so a broadcast sent while the socket was down (tab
12
31
  backgrounded, network blip) would leave the inbox stale until the user
13
- navigated. The controller re-runs the same page refresh on cable reconnect
14
- and on return-to-visible. It needs to wrap the <turbo-cable-stream-source>
15
- that `turbo_stream_from` renders so it can observe its `connected`
16
- attribute as the heartbeat. %>
32
+ navigated. The controller re-runs the same page refresh when the stream
33
+ reconnects, when the page comes back after 10 seconds hidden, when the
34
+ network comes back and when the browser restores the page (see
35
+ chats/recovery.js), and reruns a refresh Turbo dropped because it
36
+ arrived mid-visit. It wraps the <turbo-cable-stream-source> that
37
+ `turbo_stream_from` renders: the stream's own confirmations are how it
38
+ sees a reconnect. One per page: don't wrap it in a reconciler of your
39
+ own as well. %>
17
40
 
18
41
  <%# The OTHER inbox-staleness case, distinct from a missed broadcast: a Turbo
19
42
  RESTORATION visit (browser back / Hotwire Native stack pop) serves the
20
43
  inbox's cached snapshot with no GET. Open a chat from a profile, send a
21
44
  message, tap back — the snapshot cached before the chat existed comes back
22
45
  and the new conversation is missing until you navigate away and return.
23
- The reconciler above can't help (it only fires on reconnect / visibility,
24
- not on a restore visit). `turbo-cache-control: no-cache` makes the inbox
46
+ The wrapper above can't help (it fires on reconnect and visibility, not
47
+ on a restore visit). `turbo-cache-control: no-cache` makes the inbox
25
48
  uncacheable, so a restore visit re-fetches from the network and is always
26
49
  fresh — on the web AND in Hotwire Native, whose back-pop issues a Turbo
27
50
  `.restore` visit that honors this. Requires the host layout to
@@ -35,8 +58,9 @@
35
58
  <%= turbo_stream_from chats_current_messager, :chats_inbox %>
36
59
  </div>
37
60
 
38
- <%# data-chats-dock-hide: the messaging dock hides itself on this page. %>
39
- <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 %>
40
64
  <header class="chats-inbox__header">
41
65
  <h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
42
66
  </header>
@@ -49,15 +73,26 @@
49
73
  <% end %>
50
74
 
51
75
  <% if Chats.config.search && !@inbox.filtered? %>
52
- <%= form_with url: conversations_path,
53
- 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,
54
85
  class: "chats-search",
55
86
  role: "search",
56
87
  data: {
57
88
  controller: "chats--debounced-submit",
58
89
  action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
59
90
  "chats--debounced-submit-delay-value": 250,
60
- turbo_frame: "chats_inbox_results"
91
+ turbo_frame: "chats_inbox_results",
92
+ # The query goes in the URL (replaced, not pushed): a page
93
+ # refresh re-requests the URL, so a live update or a catch-up
94
+ # keeps the results and the field as they are.
95
+ turbo_action: "replace"
61
96
  } do %>
62
97
  <%= search_field_tag :q, params[:q],
63
98
  placeholder: t("chats.inbox.search_placeholder"),
@@ -75,12 +110,17 @@
75
110
  <ul class="chats-inbox__list">
76
111
  <% @rows.each do |row| %>
77
112
  <% if row.is_a?(Chats::InboxGroup) %>
78
- <%= 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 %>
79
116
  <% else %>
80
117
  <%= render "chats/conversations/conversation_row",
81
118
  conversation: row,
82
119
  viewer: chats_current_messager,
83
- 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 %>
84
124
  <% end %>
85
125
  <% end %>
86
126
  </ul>
@@ -89,7 +129,8 @@
89
129
  <p class="chats-empty__title"><%= t("chats.inbox.no_results_title") %></p>
90
130
  <p class="chats-empty__hint"><%= t("chats.inbox.no_results_hint", query: params[:q]) %></p>
91
131
  </div>
92
- <% else %>
132
+ <% elsif !split %>
133
+ <%# (Two panes: the empty pane says it, once — chats/conversations/_empty_pane.) %>
93
134
  <div class="chats-empty">
94
135
  <span class="chats-empty__icon" aria-hidden="true">💬</span>
95
136
  <p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
@@ -98,4 +139,39 @@
98
139
  </div>
99
140
  <% end %>
100
141
  <% end %>
101
- </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,7 +1,7 @@
1
- <%# Stale-thread catch-up payload (ConversationsController#refresh):
2
- append what arrived while the client slept, replace what changed.
3
- Appends are idempotent by dom_id, so a message that raced in through
4
- the live stream during the fetch can't duplicate. %>
1
+ <%# Stale-thread catch-up payload (ConversationsController#refresh): what the
2
+ stream would have delivered while the client wasn't listening. Appends
3
+ are idempotent by dom_id, so a message that raced in through the live
4
+ stream during the fetch can't duplicate. %>
5
5
  <%= turbo_stream.append dom_id(@conversation, :messages) do %>
6
6
  <%= render partial: "chats/messages/message", collection: @new_messages, as: :message %>
7
7
  <% end if @new_messages.any? %>
@@ -11,3 +11,23 @@
11
11
  partial: "chats/messages/message",
12
12
  locals: { message: message } %>
13
13
  <% end %>
14
+
15
+ <%# Who has read what: a read the stream missed would leave "Sent" where it
16
+ should say "Seen". %>
17
+ <%= turbo_stream.replace dom_id(@conversation, :read_state),
18
+ partial: "chats/conversations/read_state",
19
+ locals: { conversation: @conversation } %>
20
+
21
+ <%# `deletion: :hard` only: every message from the client's oldest bubble on
22
+ that still exists (the chats_prune action, registered by the thread
23
+ controller, removes the rest). %>
24
+ <%# Where the client stands now (ConversationsController#caught_up_cursors):
25
+ its cursors move only when an answer like this one arrives. %>
26
+ <%= @caught_up %>
27
+
28
+ <% if @present %>
29
+ <%= turbo_stream_action_tag :chats_prune,
30
+ target: dom_id(@conversation, :history),
31
+ "data-ids": @present[:ids].join(" "),
32
+ "data-until": @present[:until] %>
33
+ <% end %>