chats 0.1.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1037 -0
  3. data/README.md +621 -40
  4. data/app/assets/stylesheets/chats.css +906 -2
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +396 -86
  7. data/app/controllers/chats/messages_controller.rb +35 -13
  8. data/app/controllers/chats/reactions_controller.rb +14 -0
  9. data/app/helpers/chats/engine_helper.rb +230 -7
  10. data/app/javascript/chats/composer_controller.js +14 -1
  11. data/app/javascript/chats/dock_controller.js +1120 -0
  12. data/app/javascript/chats/recovery.js +539 -0
  13. data/app/javascript/chats/refresh_inbox_controller.js +76 -0
  14. data/app/javascript/chats/split_controller.js +131 -0
  15. data/app/javascript/chats/thread_controller.js +322 -60
  16. data/app/javascript/chats/unread_badge_controller.js +24 -0
  17. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  18. data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
  19. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  20. data/app/views/chats/conversations/_group.html.erb +61 -0
  21. data/app/views/chats/conversations/_history.html.erb +54 -0
  22. data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
  23. data/app/views/chats/conversations/_thread.html.erb +147 -0
  24. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  25. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  26. data/app/views/chats/conversations/dock.html.erb +40 -0
  27. data/app/views/chats/conversations/index.html.erb +142 -15
  28. data/app/views/chats/conversations/pane.html.erb +14 -0
  29. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  30. data/app/views/chats/conversations/show.html.erb +18 -134
  31. data/app/views/chats/conversations/window.html.erb +26 -0
  32. data/app/views/chats/dock/_badge.html.erb +12 -0
  33. data/app/views/chats/dock/_dock.html.erb +139 -0
  34. data/app/views/chats/dock/_signal.html.erb +26 -0
  35. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  36. data/app/views/chats/dock/_window.html.erb +23 -0
  37. data/app/views/chats/dock/_window_header.html.erb +75 -0
  38. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  39. data/app/views/chats/messages/_action.html.erb +15 -0
  40. data/app/views/chats/messages/_composer.html.erb +84 -56
  41. data/app/views/chats/messages/_message.html.erb +43 -15
  42. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  43. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  44. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  45. data/config/importmap.rb +10 -1
  46. data/config/locales/en.yml +40 -0
  47. data/config/locales/es.yml +40 -0
  48. data/config/routes.rb +8 -0
  49. data/context7.json +4 -0
  50. data/docs/PRD.md +1 -1
  51. data/docs/campfire_review.md +1 -1
  52. data/docs/screenshots/composer-actions.png +0 -0
  53. data/docs/screenshots/dock-bar.png +0 -0
  54. data/docs/screenshots/dock-desktop.png +0 -0
  55. data/docs/screenshots/dock-phone-button.png +0 -0
  56. data/docs/screenshots/dock-phone-panel.png +0 -0
  57. data/docs/screenshots/dock-phone-window.png +0 -0
  58. data/docs/screenshots/inbox.png +0 -0
  59. data/docs/screenshots/split-inbox-empty.png +0 -0
  60. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  61. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  62. data/docs/screenshots/split-inbox.png +0 -0
  63. data/docs/screenshots/system-note-action.png +0 -0
  64. data/docs/screenshots/thread-actions-phone.png +0 -0
  65. data/gemfiles/rails_7.1.gemfile +3 -0
  66. data/gemfiles/rails_7.2.gemfile +3 -0
  67. data/gemfiles/rails_8.1.gemfile +3 -0
  68. data/lib/chats/action.rb +85 -0
  69. data/lib/chats/broadcasts.rb +178 -17
  70. data/lib/chats/configuration.rb +115 -1
  71. data/lib/chats/engine.rb +29 -7
  72. data/lib/chats/errors.rb +16 -0
  73. data/lib/chats/inbox.rb +363 -0
  74. data/lib/chats/inbox_group.rb +75 -0
  75. data/lib/chats/inbox_refresh_job.rb +14 -0
  76. data/lib/chats/macros.rb +33 -1
  77. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  78. data/lib/chats/models/concerns/messager.rb +122 -6
  79. data/lib/chats/models/conversation.rb +153 -19
  80. data/lib/chats/models/message.rb +292 -21
  81. data/lib/chats/models/participant.rb +67 -0
  82. data/lib/chats/models/reaction.rb +18 -0
  83. data/lib/chats/send_rate_limited.rb +30 -0
  84. data/lib/chats/subscribers.rb +156 -0
  85. data/lib/chats/version.rb +1 -1
  86. data/lib/chats.rb +123 -15
  87. data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
  88. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  89. data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
  90. data/lib/generators/chats/templates/initializer.rb +125 -14
  91. data/lib/generators/chats/upgrade_generator.rb +79 -0
  92. data/lib/generators/chats/views_generator.rb +3 -2
  93. metadata +51 -2
@@ -1,25 +1,101 @@
1
1
  <%# The inbox. Subscribed to the viewer's inbox stream: new activity anywhere
2
2
  triggers a Turbo 8 page refresh (morphing, scroll-preserving), which
3
3
  re-renders this page per-viewer — see Chats::Broadcasts for why refreshes
4
- beat surgical row patches here. %>
4
+ beat surgical row patches here.
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
+
25
+ Rows come from Chats::Inbox and are either a Chats::Conversation or a
26
+ Chats::InboxGroup (every direct thread with a `inbox: :grouped` messager,
27
+ stacked into one row).
28
+
29
+ The chats--refresh-inbox wrapper heals a MISSED refresh broadcast: Action
30
+ Cable has no replay, so a broadcast sent while the socket was down (tab
31
+ backgrounded, network blip) would leave the inbox stale until the user
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. %>
40
+
41
+ <%# The OTHER inbox-staleness case, distinct from a missed broadcast: a Turbo
42
+ RESTORATION visit (browser back / Hotwire Native stack pop) serves the
43
+ inbox's cached snapshot with no GET. Open a chat from a profile, send a
44
+ message, tap back — the snapshot cached before the chat existed comes back
45
+ and the new conversation is missing until you navigate away and return.
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
48
+ uncacheable, so a restore visit re-fetches from the network and is always
49
+ fresh — on the web AND in Hotwire Native, whose back-pop issues a Turbo
50
+ `.restore` visit that honors this. Requires the host layout to
51
+ `yield :head` (the Rails convention); harmless no-op otherwise.
52
+ https://turbo.hotwired.dev/handbook/building#opting-out-of-caching %>
53
+ <%# The page's title, for a layout that shows `yield(:title)`: the inbox's,
54
+ or the open conversation's (chats_page_title, config.page_title). %>
55
+ <% content_for :title, chats_page_title(@conversation) %>
56
+ <% content_for :head do %>
57
+ <meta name="turbo-cache-control" content="no-cache">
58
+ <% end %>
5
59
  <%= chats_styles %>
6
- <%= turbo_stream_from chats_current_messager, :chats_inbox %>
60
+ <div data-controller="chats--refresh-inbox">
61
+ <%= turbo_stream_from chats_current_messager, :chats_inbox %>
62
+ </div>
7
63
 
8
- <div class="chats chats-inbox">
64
+ <% split = chats_two_panes? %>
65
+ <% inbox_empty = @rows.none? && params[:q].blank? %>
66
+ <% list = capture do %>
9
67
  <header class="chats-inbox__header">
10
68
  <h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
11
69
  </header>
12
70
 
13
- <% if Chats.config.search %>
14
- <%= form_with url: conversations_path,
15
- method: :get,
71
+ <% if @inbox.filtered? %>
72
+ <p class="chats-inbox__filter">
73
+ <%= t("chats.inbox.filtered_by", name: Chats.display_name_for(@inbox.with)) %>
74
+ <%= link_to t("chats.inbox.clear_filter"), conversations_path, class: "chats-inbox__filter-clear" %>
75
+ </p>
76
+ <% end %>
77
+
78
+ <% if Chats.config.search && !@inbox.filtered? %>
79
+ <%# No `action` (so a bare tag.form: form_with always writes one): a form
80
+ without one submits to the page's own URL (the HTML standard, and
81
+ Turbo too), whatever that is NOW — /messages,
82
+ /messages/ (the engine answers both), or /messages/:id after a row
83
+ opened a conversation in the pane and moved the address bar. So the
84
+ URL a search puts in the address bar is still this page to Turbo,
85
+ the next refresh morphs it instead of re-rendering it from scratch,
86
+ and on the two-pane page the open conversation stays open. %>
87
+ <%= tag.form method: :get,
16
88
  class: "chats-search",
17
89
  role: "search",
18
90
  data: {
19
91
  controller: "chats--debounced-submit",
20
92
  action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
21
93
  "chats--debounced-submit-delay-value": 250,
22
- turbo_frame: "chats_inbox_results"
94
+ turbo_frame: "chats_inbox_results",
95
+ # The query goes in the URL (replaced, not pushed): a page
96
+ # refresh re-requests the URL, so a live update or a catch-up
97
+ # keeps the results and the field as they are.
98
+ turbo_action: "replace"
23
99
  } do %>
24
100
  <%= search_field_tag :q, params[:q],
25
101
  placeholder: t("chats.inbox.search_placeholder"),
@@ -29,13 +105,26 @@
29
105
  <% end %>
30
106
 
31
107
  <%= turbo_frame_tag "chats_inbox_results", target: "_top" do %>
32
- <% if @conversations.any? %>
108
+ <%# Slot: anything the host wants above the first row — a support door,
109
+ an announcement, a filter bar. Renders only when the partial exists. %>
110
+ <%= chats_slot :inbox_top, viewer: chats_current_messager, inbox: @inbox %>
111
+
112
+ <% if @rows.any? %>
33
113
  <ul class="chats-inbox__list">
34
- <% @conversations.each do |conversation| %>
35
- <%= render "chats/conversations/conversation_row",
36
- conversation: conversation,
37
- viewer: chats_current_messager,
38
- unread_count: @unread_counts.fetch(conversation.id, 0) %>
114
+ <% @rows.each do |row| %>
115
+ <% if row.is_a?(Chats::InboxGroup) %>
116
+ <%= render "chats/conversations/group", group: row, viewer: chats_current_messager,
117
+ pane: split, current: @conversation,
118
+ filter: chats_inbox_filter %>
119
+ <% else %>
120
+ <%= render "chats/conversations/conversation_row",
121
+ conversation: row,
122
+ viewer: chats_current_messager,
123
+ unread_count: @unread_counts.fetch(row.id, 0),
124
+ pane: split,
125
+ current: @conversation,
126
+ filter: chats_inbox_filter %>
127
+ <% end %>
39
128
  <% end %>
40
129
  </ul>
41
130
  <% elsif params[:q].present? %>
@@ -43,12 +132,50 @@
43
132
  <p class="chats-empty__title"><%= t("chats.inbox.no_results_title") %></p>
44
133
  <p class="chats-empty__hint"><%= t("chats.inbox.no_results_hint", query: params[:q]) %></p>
45
134
  </div>
46
- <% else %>
135
+ <% elsif !split %>
136
+ <%# (Two panes: the empty pane says it, once — chats/conversations/_empty_pane.) %>
47
137
  <div class="chats-empty">
48
138
  <span class="chats-empty__icon" aria-hidden="true">💬</span>
49
139
  <p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
50
140
  <p class="chats-empty__hint"><%= t("chats.inbox.empty_hint") %></p>
141
+ <%= chats_slot :inbox_empty, viewer: chats_current_messager, inbox: @inbox %>
51
142
  </div>
52
143
  <% end %>
53
144
  <% end %>
54
- </div>
145
+ <% end %>
146
+
147
+ <%# data-chats-dock-hide: the messaging dock hides itself on this page. %>
148
+ <% if split %>
149
+ <%= tag.div class: "chats chats-inbox chats-split",
150
+ data: {
151
+ chats_dock_hide: true,
152
+ controller: "chats--split",
153
+ action: "turbo:before-prefetch->chats--split#prefetch " \
154
+ "turbo:frame-load->chats--split#opened " \
155
+ "chats:read->chats--split#threadRead " \
156
+ "turbo:before-morph-attribute->chats--split#keepEnabled"
157
+ } do %>
158
+ <nav class="chats-split__list" aria-label="<%= t("chats.inbox.title") %>" data-chats--split-target="list">
159
+ <%= list %>
160
+ </nav>
161
+
162
+ <%= turbo_frame_tag Chats::ConversationsController::PANE_FRAME,
163
+ class: "chats-split__conversation",
164
+ target: "_top",
165
+ disabled: true,
166
+ data: { turbo_action: "advance", "chats--split-target": "frame" } do %>
167
+ <% if @conversation %>
168
+ <%= render "chats/conversations/conversation_pane",
169
+ conversation: @conversation, participant: @participant,
170
+ messages: @messages, more: @more_messages, first_unread_id: @first_unread_id,
171
+ counterpart: chats_counterpart, read_pending: @read_pending %>
172
+ <% else %>
173
+ <%= render "chats/conversations/empty_pane", inbox_empty: inbox_empty, inbox: @inbox %>
174
+ <% end %>
175
+ <% end %>
176
+ <% end %>
177
+ <% else %>
178
+ <div class="chats chats-inbox" data-chats-dock-hide>
179
+ <%= list %>
180
+ </div>
181
+ <% end %>
@@ -0,0 +1,14 @@
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, turbo-rails' minimal frame layout
5
+ (ConversationsController#show). The pane carries its title
6
+ (data-title), which chats--split gives the tab.
7
+ Turbo swaps it into the frame and, the frame being
8
+ data-turbo-action="advance", moves the address bar to /messages/:id. %>
9
+ <%= turbo_frame_tag Chats::ConversationsController::PANE_FRAME do %>
10
+ <%= render "chats/conversations/conversation_pane",
11
+ conversation: @conversation, participant: @participant,
12
+ messages: @messages, more: @more_messages, first_unread_id: @first_unread_id,
13
+ counterpart: chats_counterpart, read_pending: @read_pending %>
14
+ <% 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 %>
@@ -1,137 +1,21 @@
1
- <%# The thread. One conversation stream powers everything live here:
2
- message append/replace/remove, the read-state payload, and typing pings.
3
- Bubbles arrive VIEWER-AGNOSTIC from shared broadcasts; the chats--thread
4
- Stimulus controller aligns own-vs-other by comparing each bubble's
5
- sender key against its `me` value (that's what makes one broadcast render
6
- work for every subscriber). %>
1
+ <%# The thread page 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). %>
7
+ <% content_for :title, chats_page_title(@conversation) %>
7
8
  <%= chats_styles %>
8
9
 
9
- <%= tag.div class: "chats chats-thread", data: {
10
- controller: "chats--thread",
11
- "chats--thread-me-value": Chats.messager_key(chats_current_messager),
12
- "chats--thread-read-url-value": read_conversation_path(@conversation),
13
- "chats--thread-sent-label-value": t("chats.thread.sent"),
14
- "chats--thread-seen-label-value": Chats.config.read_receipts ? t("chats.thread.seen") : "",
15
- "chats--thread-today-label-value": t("chats.thread.today"),
16
- "chats--thread-yesterday-label-value": t("chats.thread.yesterday"),
17
- "chats--thread-typing-suffix-value": t("chats.thread.typing_suffix"),
18
- "chats--thread-group-value": @conversation.group?,
19
- "chats--thread-refresh-url-value": refresh_conversation_path(@conversation),
20
- "chats--thread-thread-url-value": conversation_path(@conversation),
21
- "chats--thread-copied-label-value": t("chats.message.copied"),
22
- action: "pointerdown->chats--thread#pressStart pointermove->chats--thread#pressMove " \
23
- "pointerup->chats--thread#pressEnd pointercancel->chats--thread#pressCancel " \
24
- "contextmenu->chats--thread#contextMenu"
25
- } do %>
26
-
27
- <%= turbo_stream_from @conversation %>
28
-
29
- <header class="chats-thread__header">
30
- <%= link_to root_path, class: "chats-thread__back", "aria-label": t("chats.thread.back") do %>
31
- <span aria-hidden="true">‹</span>
32
- <% end %>
33
-
34
- <%= chats_conversation_avatar(@conversation, chats_current_messager) %>
35
-
36
- <div class="chats-thread__identity">
37
- <h1 class="chats-thread__title"><%= @conversation.title_for(chats_current_messager) %></h1>
38
- <% if @conversation.subject_label %>
39
- <p class="chats-thread__subject"><%= @conversation.subject_label %></p>
40
- <% end %>
41
- </div>
42
-
43
- <details class="chats-menu">
44
- <summary class="chats-menu__trigger" aria-label="<%= t("chats.thread.options") %>">⋯</summary>
45
- <div class="chats-menu__panel">
46
- <% if @participant&.muted? %>
47
- <%= button_to t("chats.thread.unmute"), unmute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
48
- <% else %>
49
- <%= button_to t("chats.thread.mute"), mute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
50
- <% end %>
51
- <% if @conversation.group? %>
52
- <%= button_to t("chats.thread.leave"), leave_conversation_path(@conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
53
- <% end %>
54
- </div>
55
- </details>
56
- </header>
57
-
58
- <div class="chats-thread__scroll" data-chats--thread-target="scroller">
59
- <% if @more_messages && @messages.any? %>
60
- <%# Lazy frame chain for infinite scroll-up: when this frame becomes
61
- visible, Turbo fetches the previous keyset page (?before=oldest id),
62
- whose response contains a frame with this SAME id — older messages
63
- plus, recursively, the next frame. See ConversationsController#show. %>
64
- <%= turbo_frame_tag "chats_page_#{@messages.first.id}",
65
- src: conversation_path(@conversation, before: @messages.first.id),
66
- loading: :lazy do %>
67
- <div class="chats-loader"><%= t("chats.thread.loading") %></div>
68
- <% end %>
69
- <% end %>
70
-
71
- <div id="<%= dom_id(@conversation, :messages) %>" class="chats-thread__messages">
72
- <%# Explicit loop (not a collection render) so the «new messages»
73
- divider can sit BEFORE the first unread bubble (WhatsApp's "the
74
- catch-up starts here" line). Server-rendered on the initial page
75
- only; it scrolls away naturally as the thread lives on. %>
76
- <% @messages.each do |message| %>
77
- <% if message.id == @first_unread_id %>
78
- <div class="chats-thread__unread-line" role="separator">
79
- <span><%= t("chats.thread.new_messages") %></span>
80
- </div>
81
- <% end %>
82
- <%= render "chats/messages/message", message: message %>
83
- <% end %>
84
- </div>
85
-
86
- <%= render "chats/conversations/read_state", conversation: @conversation %>
87
-
88
- <div id="<%= dom_id(@conversation, :typing) %>"
89
- class="chats-typing"
90
- data-chats--thread-target="typing"
91
- hidden></div>
92
- </div>
93
-
94
- <%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
95
- pressed bubble's clone morphs to the center, the reactions pill sits
96
- above it and the contextual menu below. Slots are filled by cloning
97
- the bubble's <template data-chats-message-menu>. %>
98
- <div class="chats-popup"
99
- role="dialog"
100
- aria-modal="true"
101
- data-chats--thread-target="popup"
102
- hidden>
103
- <div class="chats-popup__backdrop" data-action="click->chats--thread#closePopup"></div>
104
- <div class="chats-popup__stack">
105
- <div class="chats-popup__slot chats-popup__slot--reactions"
106
- data-chats--thread-target="popupReactions"
107
- data-action="click->chats--thread#popupMenuClicked"></div>
108
- <div class="chats-popup__slot chats-popup__slot--bubble" data-chats--thread-target="popupBubble"></div>
109
- <div class="chats-popup__slot chats-popup__slot--menu"
110
- data-chats--thread-target="popupMenu"
111
- data-action="click->chats--thread#popupMenuClicked"></div>
112
- </div>
113
- </div>
114
-
115
- <div class="chats-attachment-preview"
116
- role="dialog"
117
- aria-modal="true"
118
- tabindex="-1"
119
- data-chats--thread-target="attachmentDialog"
120
- data-action="keydown.esc->chats--thread#closeAttachment"
121
- hidden>
122
- <div class="chats-attachment-preview__surface"
123
- data-action="click->chats--thread#closeAttachmentFromBackdrop">
124
- <button type="button"
125
- class="chats-attachment-preview__close"
126
- aria-label="<%= t("chats.message.close_attachment") %>"
127
- data-action="chats--thread#closeAttachment">×</button>
128
- <figure class="chats-attachment-preview__figure">
129
- <img class="chats-attachment-preview__image"
130
- alt=""
131
- data-chats--thread-target="attachmentImage">
132
- </figure>
133
- </div>
134
- </div>
135
-
136
- <%= render "chats/messages/composer", conversation: @conversation %>
10
+ <%= render layout: "chats/conversations/thread",
11
+ locals: {
12
+ conversation: @conversation,
13
+ messages: @messages,
14
+ more: @more_messages,
15
+ first_unread_id: @first_unread_id,
16
+ counterpart: chats_counterpart,
17
+ read_pending: @read_pending
18
+ } do %>
19
+ <%= render "chats/conversations/thread_header",
20
+ conversation: @conversation, participant: @participant, counterpart: chats_counterpart %>
137
21
  <% end %>
@@ -0,0 +1,26 @@
1
+ <%# A floating dock window's content (GET /:id?window=1 from inside the
2
+ window's turbo frame — see chats/dock/_window for the frame and
3
+ ConversationsController#show): the SAME thread body the page renders,
4
+ rooted at .chats-thread--window, under the window's compact header.
5
+
6
+ The header's chats--dock actions reach the dock's controller because the
7
+ window lives inside the dock element. The title is a button: it
8
+ minimizes and expands the window (LinkedIn's header click), and says
9
+ which way with aria-expanded. %>
10
+ <% title = @conversation.title_for(chats_current_messager) %>
11
+ <%= turbo_frame_tag dom_id(@conversation, :window) do %>
12
+ <section class="chats-window__card" role="region" aria-label="<%= t("chats.dock.window_label", name: title) %>">
13
+ <%= render layout: "chats/conversations/thread",
14
+ locals: {
15
+ conversation: @conversation,
16
+ messages: @messages,
17
+ more: @more_messages,
18
+ first_unread_id: @first_unread_id,
19
+ counterpart: chats_counterpart,
20
+ window: true
21
+ } do %>
22
+ <%= render "chats/dock/window_header", conversation: @conversation, title: title,
23
+ counterpart: chats_counterpart %>
24
+ <% end %>
25
+ </section>
26
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <%# The dock's unread badge: the same number as the nav badge
2
+ (`unread_chats_count`), replaced on the viewer's dock stream with every
3
+ other [data-chats-unread-badge] inside the dock. No id — a page may hold
4
+ the nav badge too, and ids must stay unique. Not a live region: it would
5
+ chatter on every read; new messages are announced by the dock instead.
6
+ Hidden at zero, number kept. %>
7
+ <%= tag.span class: "chats-badge chats-dock__badge",
8
+ hidden: !count.positive?,
9
+ data: { chats_unread_badge: true, "chats--dock-target": "badge", count: count } do %>
10
+ <span aria-hidden="true"><%= count > 99 ? "99+" : count %></span>
11
+ <span class="chats-visually-hidden"><%= count %> <%= t("chats.dock.unread_suffix") %></span>
12
+ <% end %>
@@ -0,0 +1,139 @@
1
+ <%# The messaging dock (`chats_dock` in the host's layout): fixed to the
2
+ bottom-right corner, LinkedIn / Steam style. Left to right, the floating
3
+ conversation WINDOWS (newest nearest the panel), then the PANEL: its
4
+ header is the collapsed bar (avatar, "Messages", unread badge), and open
5
+ it holds search, the recent list and the way to the full inbox. On a
6
+ phone (chats.css: under 700px wide or 500px tall) the closed bar is a
7
+ round button, the open panel a full-screen sheet.
8
+
9
+ data-turbo-permanent: Turbo Drive carries this element from page to page
10
+ as it is — open windows, scroll positions, drafts and cable
11
+ subscriptions included — as long as the next page renders a dock with
12
+ the same id. The id names the VIEWER, so a visit that lands on another
13
+ account's page (sign out and in, an account switcher) never matches and
14
+ never carries one person's windows into another's page.
15
+
16
+ The panel is a <details>: it opens and closes with no JavaScript at all,
17
+ and its rows, search and links are real links and forms. The chats--dock
18
+ controller (dock_controller.js) does the rest. Its live signal is the
19
+ template on the viewer's own `[messager, :chats_dock]` stream (see
20
+ chats/dock/_signal and Chats::Broadcasts). %>
21
+ <% key = Chats.messager_key(viewer) %>
22
+ <%= chats_styles %>
23
+ <%= tag.aside id: "chats_dock_#{key}",
24
+ class: ["chats-dock", ("chats-dock--no-launcher" unless launcher)],
25
+ "aria-label": t("chats.dock.title"),
26
+ data: {
27
+ turbo_permanent: true,
28
+ chats_messager: key,
29
+ controller: "chats--dock",
30
+ "chats--dock-windows-value": windows,
31
+ "chats--dock-messager-value": key,
32
+ "chats--dock-everywhere-value": everywhere,
33
+ "chats--dock-window-url-value": chats_routes.conversation_path("__id__", window: 1),
34
+ "chats--dock-record-key-value": Chats::Conversation.model_name.param_key,
35
+ "chats--dock-badge-url-value": chats_routes.badge_conversations_path,
36
+ "chats--dock-announcement-value": t("chats.dock.new_message_from", name: "%{name}"),
37
+ # Every plural form the locale has (CLDR: zero, one, two, few, many,
38
+ # other), uninterpolated: the controller picks one with
39
+ # Intl.PluralRules and fills %{count} in.
40
+ "chats--dock-window-unread-value": chats_plural_forms("chats.dock.window_unread").to_json,
41
+ action: "keydown->chats--dock#keydown " \
42
+ "focusin->chats--dock#engage pointerdown->chats--dock#engage wheel->chats--dock#engage " \
43
+ "touchstart->chats--dock#engage " \
44
+ "turbo:before-fetch-response->chats--dock#frameResponded " \
45
+ "turbo:frame-missing->chats--dock#frameMissing " \
46
+ "turbo:frame-load->chats--dock#frameLoaded " \
47
+ "turbo:fetch-request-error->chats--dock#frameFailed " \
48
+ "turbo:submit-end->chats--dock#sent " \
49
+ "input->chats--dock#typed " \
50
+ "click@document->chats--dock#delegate " \
51
+ "turbo:before-stream-render@document->chats--dock#beforeStreamRender " \
52
+ "turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged " \
53
+ "turbo:frame-load@document->chats--dock#pageFrameLoaded"
54
+ } do %>
55
+ <%= turbo_stream_from viewer, :chats_dock %>
56
+ <%= render "chats/dock/signal" %>
57
+
58
+ <div id="chats_dock_windows" class="chats-dock__windows" data-chats--dock-target="windows"></div>
59
+
60
+ <%# What a window shows before its thread arrives, and if it can't. %>
61
+ <template data-chats--dock-target="loadingShell"><%= render "chats/dock/window_shell", state: :loading %></template>
62
+ <template data-chats--dock-target="failedShell"><%= render "chats/dock/window_shell", state: :failed %></template>
63
+
64
+ <details class="chats-dock__panel"
65
+ data-chats--dock-target="panel"
66
+ data-action="toggle->chats--dock#panelToggled">
67
+ <%# The summary reads "Messages, 3 unread" from its own text; on a phone
68
+ the closed one is the round launcher (chats.css). %>
69
+ <summary class="chats-dock__bar chats-dock__toggle">
70
+ <%= chats_messager_avatar(viewer) %>
71
+ <svg class="chats-dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
72
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
73
+ <path d="M12 3a9 9 0 0 0-7.8 13.5L3 21l4.6-1.2A9 9 0 1 0 12 3Z" />
74
+ </svg>
75
+ <span class="chats-dock__title"><%= t("chats.dock.title") %></span>
76
+ <%= render "chats/dock/badge", count: viewer.unread_chats_count %>
77
+ <span class="chats-dock__chevron" aria-hidden="true">
78
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
79
+ stroke-linejoin="round" focusable="false"><path d="m6 15 6-6 6 6" /></svg>
80
+ </span>
81
+ <span class="chats-dock__close-glyph" aria-hidden="true">
82
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
83
+ focusable="false"><path d="M6 6l12 12M18 6 6 18" /></svg>
84
+ </span>
85
+ </summary>
86
+
87
+ <div class="chats-dock__body">
88
+ <% if Chats.config.search %>
89
+ <%# The inbox's own search: with JavaScript the results land in the
90
+ dock's frame (the inbox answers that frame with the dock's list);
91
+ without it, on the full inbox. %>
92
+ <%= form_with url: chats_routes.conversations_path,
93
+ method: :get,
94
+ class: "chats-search chats-dock__search",
95
+ # `html:`: form_with drops attributes it doesn't know (role among them).
96
+ html: { role: "search" },
97
+ data: {
98
+ controller: "chats--debounced-submit",
99
+ action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
100
+ "chats--debounced-submit-delay-value": 250,
101
+ turbo_frame: "chats_dock_list"
102
+ } do %>
103
+ <%= hidden_field_tag :recent, recent, id: nil %>
104
+ <%= search_field_tag :q, nil,
105
+ id: "chats_dock_q",
106
+ placeholder: t("chats.inbox.search_placeholder"),
107
+ "aria-label": t("chats.inbox.search_placeholder"),
108
+ class: "chats-search__input",
109
+ autocomplete: "off" %>
110
+ <% end %>
111
+ <% end %>
112
+
113
+ <%# Lazy: nothing is fetched until the panel first opens; until then,
114
+ four still rows. The controller reloads it when the signal says,
115
+ and when its stream may have missed a signal. `refresh: :morph`:
116
+ a reload morphs the rows in place, so the list keeps its scroll
117
+ position, and it reloads its own src, so a search stays. %>
118
+ <%= turbo_frame_tag "chats_dock_list",
119
+ src: chats_routes.dock_conversations_path(recent: recent),
120
+ loading: :lazy,
121
+ refresh: :morph,
122
+ target: "_top",
123
+ class: "chats-dock__results",
124
+ # A click here opens a window, never the page: hovering a row
125
+ # must not prefetch it.
126
+ data: { "chats--dock-target": "list", turbo_prefetch: false } do %>
127
+ <div aria-busy="true" aria-label="<%= t("chats.thread.loading") %>">
128
+ <% 4.times do %><div class="chats-dock__skeleton"><span></span></div><% end %>
129
+ </div>
130
+ <% end %>
131
+
132
+ <%= link_to t("chats.dock.see_all"), chats_routes.conversations_path, class: "chats-dock__see-all" %>
133
+ <%= chats_slot :dock_panel_footer, viewer: viewer %>
134
+ </div>
135
+ </details>
136
+
137
+ <%# "New message from …": polite, one at a time. The badge is not live. %>
138
+ <div class="chats-visually-hidden" aria-live="polite" aria-atomic="true" data-chats--dock-target="announcer"></div>
139
+ <% end %>
@@ -0,0 +1,26 @@
1
+ <%# What just changed, for the dock's controller (it acts when the replaced
2
+ element connects, and consumes data-changed so a Turbo Drive visit that
3
+ reconnects the same node never acts twice). An inert <template>: it
4
+ renders nothing. The dock renders it bare; Chats::Broadcasts sends it
5
+ with the signal:
6
+
7
+ data-changed reload the recent list
8
+ data-conversation-id the conversation it was about, when the
9
+ viewer can see it
10
+ data-notify a message they'd be notified about: its
11
+ window may pop open, and it is announced
12
+ data-sender-name who it is from, for the announcement
13
+ data-unread-count unread there now, for the window's header
14
+
15
+ Strings for people are rendered by the dock in the VIEWER's locale
16
+ (Stimulus values), never here: a broadcast renders in no one's. %>
17
+ <% signal = local_assigns[:signal] %>
18
+ <%= tag.template id: "chats_dock_signal",
19
+ data: {
20
+ "chats--dock-target": "signal",
21
+ changed: (true if signal),
22
+ conversation_id: signal&.dig(:conversation_id),
23
+ notify: (true if signal&.dig(:notify)),
24
+ sender_name: signal&.dig(:sender_name),
25
+ unread_count: signal&.dig(:unread_count)
26
+ } %>
@@ -0,0 +1,10 @@
1
+ <%# One dock update (Chats::Broadcasts.update_dock_of), rendered per
2
+ participant and sent on their own `[messager, :chats_dock]` stream:
3
+ the badges when the unread count moved, and the signal. %>
4
+ <% if count %>
5
+ <%= turbo_stream.replace_all ".chats-dock [data-chats-unread-badge]",
6
+ partial: "chats/dock/badge", locals: { count: count } %>
7
+ <% end %>
8
+ <% if signal %>
9
+ <%= turbo_stream.replace "chats_dock_signal", partial: "chats/dock/signal", locals: { signal: signal } %>
10
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <%# A floating window's FRAME, loading its thread from `?window=1`
2
+ (conversations/window.html.erb). Appended to #chats_dock_windows by
3
+ ConversationsController#create (`chat_button_to …, dock: true`); the
4
+ dock's controller builds the identical element itself when a row or a
5
+ live message opens one (dock_controller.js, buildWindow) — keep the two
6
+ in step. A conversation already open in a window is focused instead of
7
+ reloaded (the controller intercepts the append).
8
+
9
+ `target: "_top"`: links inside a window open the page, never the frame.
10
+ `data-chats-active`: the viewer opened it, so it may mark messages read.
11
+ `data-focus`: and the composer takes the focus once the thread arrives. %>
12
+ <%= turbo_frame_tag dom_id(conversation, :window),
13
+ src: chats_routes.conversation_path(conversation, window: 1),
14
+ target: "_top",
15
+ class: "chats-window",
16
+ data: {
17
+ "chats--dock-target": "window",
18
+ conversation_id: conversation.id,
19
+ chats_active: true,
20
+ focus: (true if local_assigns[:focus])
21
+ } do %>
22
+ <%= render "chats/dock/window_shell", state: :loading, title: conversation.title_for(chats_current_messager) %>
23
+ <% end %>