chats 0.5.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +171 -0
  3. data/README.md +40 -7
  4. data/app/assets/stylesheets/chats.css +131 -6
  5. data/app/controllers/chats/conversations_controller.rb +106 -11
  6. data/app/helpers/chats/engine_helper.rb +15 -0
  7. data/app/javascript/chats/dock_controller.js +15 -1
  8. data/app/javascript/chats/split_controller.js +131 -0
  9. data/app/javascript/chats/thread_controller.js +5 -0
  10. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  11. data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
  12. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  13. data/app/views/chats/conversations/_group.html.erb +20 -4
  14. data/app/views/chats/conversations/_thread.html.erb +22 -8
  15. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  16. data/app/views/chats/conversations/index.html.erb +81 -14
  17. data/app/views/chats/conversations/pane.html.erb +14 -0
  18. data/app/views/chats/conversations/show.html.erb +11 -65
  19. data/app/views/chats/dock/_dock.html.erb +2 -1
  20. data/config/importmap.rb +1 -0
  21. data/config/locales/en.yml +4 -0
  22. data/config/locales/es.yml +4 -0
  23. data/docs/screenshots/split-inbox-empty.png +0 -0
  24. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  25. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  26. data/docs/screenshots/split-inbox.png +0 -0
  27. data/lib/chats/configuration.rb +36 -0
  28. data/lib/chats/models/conversation.rb +10 -2
  29. data/lib/chats/models/message.rb +19 -3
  30. data/lib/chats/version.rb +1 -1
  31. data/lib/generators/chats/templates/initializer.rb +17 -0
  32. metadata +11 -3
  33. data/chats-0.3.1.gem +0 -0
@@ -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).
@@ -31,6 +50,9 @@
31
50
  `.restore` visit that honors this. Requires the host layout to
32
51
  `yield :head` (the Rails convention); harmless no-op otherwise.
33
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) %>
34
56
  <% content_for :head do %>
35
57
  <meta name="turbo-cache-control" content="no-cache">
36
58
  <% end %>
@@ -39,8 +61,9 @@
39
61
  <%= turbo_stream_from chats_current_messager, :chats_inbox %>
40
62
  </div>
41
63
 
42
- <%# data-chats-dock-hide: the messaging dock hides itself on this page. %>
43
- <div class="chats chats-inbox" data-chats-dock-hide>
64
+ <% split = chats_two_panes? %>
65
+ <% inbox_empty = @rows.none? && params[:q].blank? %>
66
+ <% list = capture do %>
44
67
  <header class="chats-inbox__header">
45
68
  <h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
46
69
  </header>
@@ -53,15 +76,17 @@
53
76
  <% end %>
54
77
 
55
78
  <% 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,
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,
62
88
  class: "chats-search",
63
- # `html:`: form_with drops attributes it doesn't know (role among them).
64
- html: { role: "search" },
89
+ role: "search",
65
90
  data: {
66
91
  controller: "chats--debounced-submit",
67
92
  action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
@@ -88,12 +113,17 @@
88
113
  <ul class="chats-inbox__list">
89
114
  <% @rows.each do |row| %>
90
115
  <% if row.is_a?(Chats::InboxGroup) %>
91
- <%= render "chats/conversations/group", group: row, viewer: chats_current_messager %>
116
+ <%= render "chats/conversations/group", group: row, viewer: chats_current_messager,
117
+ pane: split, current: @conversation,
118
+ filter: chats_inbox_filter %>
92
119
  <% else %>
93
120
  <%= render "chats/conversations/conversation_row",
94
121
  conversation: row,
95
122
  viewer: chats_current_messager,
96
- unread_count: @unread_counts.fetch(row.id, 0) %>
123
+ unread_count: @unread_counts.fetch(row.id, 0),
124
+ pane: split,
125
+ current: @conversation,
126
+ filter: chats_inbox_filter %>
97
127
  <% end %>
98
128
  <% end %>
99
129
  </ul>
@@ -102,7 +132,8 @@
102
132
  <p class="chats-empty__title"><%= t("chats.inbox.no_results_title") %></p>
103
133
  <p class="chats-empty__hint"><%= t("chats.inbox.no_results_hint", query: params[:q]) %></p>
104
134
  </div>
105
- <% else %>
135
+ <% elsif !split %>
136
+ <%# (Two panes: the empty pane says it, once — chats/conversations/_empty_pane.) %>
106
137
  <div class="chats-empty">
107
138
  <span class="chats-empty__icon" aria-hidden="true">💬</span>
108
139
  <p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
@@ -111,4 +142,40 @@
111
142
  </div>
112
143
  <% end %>
113
144
  <% end %>
114
- </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,6 +1,10 @@
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). %>
7
+ <% content_for :title, chats_page_title(@conversation) %>
4
8
  <%= chats_styles %>
5
9
 
6
10
  <%= render layout: "chats/conversations/thread",
@@ -9,67 +13,9 @@
9
13
  messages: @messages,
10
14
  more: @more_messages,
11
15
  first_unread_id: @first_unread_id,
12
- counterpart: chats_counterpart
16
+ counterpart: chats_counterpart,
17
+ read_pending: @read_pending
13
18
  } 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>
19
+ <%= render "chats/conversations/thread_header",
20
+ conversation: @conversation, participant: @participant, counterpart: chats_counterpart %>
75
21
  <% 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/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
@@ -1,5 +1,8 @@
1
1
  en:
2
2
  chats:
3
+ page_title:
4
+ inbox: "Messages"
5
+ conversation: "%{name} · Messages"
3
6
  inbox:
4
7
  title: "Messages"
5
8
  search_placeholder: "Search conversations"
@@ -8,6 +11,7 @@ en:
8
11
  no_results_title: "No results"
9
12
  no_results_hint: "Nothing matched “%{query}”."
10
13
  no_messages: "No messages yet"
14
+ select_hint: "Select a conversation"
11
15
  filtered_by: "Conversations with %{name}"
12
16
  clear_filter: "Show all conversations"
13
17
  group_count:
@@ -1,5 +1,8 @@
1
1
  es:
2
2
  chats:
3
+ page_title:
4
+ inbox: "Mensajes"
5
+ conversation: "%{name} · Mensajes"
3
6
  inbox:
4
7
  title: "Mensajes"
5
8
  search_placeholder: "Buscar conversaciones"
@@ -8,6 +11,7 @@ es:
8
11
  no_results_title: "Sin resultados"
9
12
  no_results_hint: "No hay nada que coincida con «%{query}»."
10
13
  no_messages: "Sin mensajes todavía"
14
+ select_hint: "Selecciona una conversación"
11
15
  filtered_by: "Conversaciones con %{name}"
12
16
  clear_filter: "Ver todas las conversaciones"
13
17
  group_count:
Binary file
@@ -15,6 +15,7 @@ module Chats
15
15
  class Configuration
16
16
  ATTACHMENT_MODES = [false, :images, :any].freeze
17
17
  DELETION_MODES = [false, :soft, :hard].freeze
18
+ INBOX_LAYOUTS = %i[split stacked].freeze
18
19
 
19
20
  # --- Host integration -----------------------------------------------------
20
21
 
@@ -92,6 +93,15 @@ module Chats
92
93
  # threads.
93
94
  attr_accessor :inbox_limit
94
95
 
96
+ # How the inbox and a conversation share the screen. `:split` (the
97
+ # default): on screens at least 700px wide, /messages is two panes, the
98
+ # conversation list on the left and the open conversation on the right,
99
+ # and /messages/:id is that same page with the conversation open
100
+ # (LinkedIn's messaging page); narrower, the list and the thread are
101
+ # separate pages. `:stacked`: separate pages at every width, as before
102
+ # 0.6.
103
+ attr_reader :inbox_layout
104
+
95
105
  # Per-sender send throttle, enforced with Rails 8's built-in controller
96
106
  # `rate_limit` when available (feature-detected; on Rails 7.1 it's a
97
107
  # no-op). Shape: `{ to: Integer, within: ActiveSupport::Duration }`.
@@ -164,6 +174,14 @@ module Chats
164
174
  # suppress the badge on some messagers without touching the models.
165
175
  attr_reader :verified_badge
166
176
 
177
+ # ->(conversation, viewer) { String } — the title of the messaging page:
178
+ # the inbox (conversation nil) or a conversation. The bundled views
179
+ # give it to `content_for :title` (a layout doing `yield(:title)` shows
180
+ # it), and the two-pane page gives the tab the conversation's when a
181
+ # row opens it in the pane. nil (the default) is the localized
182
+ # `chats.page_title.inbox` / `chats.page_title.conversation`.
183
+ attr_reader :page_title
184
+
167
185
  def initialize
168
186
  @messager_class = "User"
169
187
  @parent_controller = "::ApplicationController"
@@ -187,6 +205,7 @@ module Chats
187
205
  @max_attachments_per_message = 4
188
206
  @send_rate_limit = { to: 60, within: 60 } # 60 messages per minute per sender
189
207
  @inbox_limit = 200
208
+ @inbox_layout = :split
190
209
 
191
210
  @encrypt_messages = false
192
211
 
@@ -199,6 +218,7 @@ module Chats
199
218
  @messager_url = ->(_messager) { nil }
200
219
  @message_signature = nil
201
220
  @verified_badge = nil
221
+ @page_title = nil
202
222
 
203
223
  @messager_display_name = lambda do |messager|
204
224
  messager.try(:display_name) || messager.try(:name) ||
@@ -244,6 +264,18 @@ module Chats
244
264
  @deletion = normalized
245
265
  end
246
266
 
267
+ def inbox_layout=(value)
268
+ normalized = value.respond_to?(:to_sym) ? value.to_sym : value
269
+ unless INBOX_LAYOUTS.include?(normalized)
270
+ raise ConfigurationError, "inbox_layout must be one of #{INBOX_LAYOUTS.inspect}, got #{value.inspect}"
271
+ end
272
+
273
+ @inbox_layout = normalized
274
+ end
275
+
276
+ # Whether the inbox and the open conversation are one two-pane page.
277
+ def split_inbox? = inbox_layout == :split
278
+
247
279
  def send_rate_limit=(value)
248
280
  if value.nil?
249
281
  @send_rate_limit = nil
@@ -312,6 +344,10 @@ module Chats
312
344
  @verified_badge = value.nil? ? nil : ensure_callable(value, "verified_badge")
313
345
  end
314
346
 
347
+ def page_title=(value)
348
+ @page_title = value.nil? ? nil : ensure_callable(value, "page_title")
349
+ end
350
+
315
351
  def messager_display_name=(value)
316
352
  @messager_display_name = ensure_callable(value, "messager_display_name")
317
353
  end
@@ -449,11 +449,19 @@ module Chats
449
449
  field.to_s == "title" && title.present?
450
450
  end
451
451
 
452
+ # Whether anything was removed (see Chats::Message#remove_reported_field!):
453
+ # false for a title already gone, decided under the row lock.
452
454
  def remove_reported_field!(field)
453
455
  return false unless field.to_s == "title"
454
456
 
455
- update!(title: nil)
456
- true
457
+ with_lock do
458
+ next false if title.blank?
459
+
460
+ update!(title: nil)
461
+ true
462
+ end
463
+ rescue ActiveRecord::RecordNotFound
464
+ false
457
465
  end
458
466
 
459
467
  def report_visible_to?(viewer, field: nil)
@@ -356,12 +356,28 @@ module Chats
356
356
  # A moderator removing a reported message body = the soft-delete
357
357
  # tombstone path, so the thread shows "Message deleted" instead of a
358
358
  # hole, and attachments are purged with it.
359
+ #
360
+ # Returns whether anything was removed: true only when it actually
361
+ # removed something (the tombstone, or the row under `deletion: :hard`);
362
+ # false for a message already deleted (by its sender, by an earlier
363
+ # decision), already gone, or whose body is already blank. moderate
364
+ # 1.0.0.beta3 and later take that as the truth and refuse a removal that
365
+ # removed nothing, rather than record "content removed" with a statement
366
+ # of reasons that would be false; earlier versions ignore the return
367
+ # value. Decided under the message's row lock, read again under it: two
368
+ # decisions taken at once cannot both see it standing.
359
369
  def remove_reported_field!(field)
360
370
  return false unless field.to_s == "body"
361
371
 
362
- # Trust & Safety outranks a product lock: a closed conversation must
363
- # never be a place reported content can hide.
364
- soft_delete!(enforce_lock: false)
372
+ with_lock do
373
+ next false if deleted? || body.blank?
374
+
375
+ # Trust & Safety outranks a product lock: a closed conversation must
376
+ # never be a place reported content can hide.
377
+ soft_delete!(enforce_lock: false)
378
+ end
379
+ rescue ActiveRecord::RecordNotFound
380
+ false # hard-deleted meanwhile: nothing left to remove
365
381
  end
366
382
 
367
383
  # Only people *in* the conversation may report a message (a message
data/lib/chats/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chats
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.2"
5
5
  end
@@ -84,6 +84,23 @@ Chats.configure do |config|
84
84
  #
85
85
  # config.inbox_limit = 200
86
86
  #
87
+ # The messaging page's layout. :split (the default): from 700px wide,
88
+ # /messages is two panes, LinkedIn-style: the conversation list on the
89
+ # left, the open conversation on the right (/messages/:id is that same
90
+ # page, its conversation open); narrower, the list and the thread are
91
+ # separate pages. :stacked keeps them separate pages at every width.
92
+ # (Views you ejected with `rails g chats:views` keep rendering as they are.)
93
+ #
94
+ # config.inbox_layout = :split
95
+ #
96
+ # The messaging page's title (the bundled views give it to
97
+ # `content_for :title`, and the two-pane page to the tab when a row opens
98
+ # a conversation). nil: "Messages" / "Bob · Messages" (chats.page_title.*).
99
+ #
100
+ # config.page_title = ->(conversation, viewer) {
101
+ # conversation ? "#{conversation.title_for(viewer)} | MyApp" : "Messages | MyApp"
102
+ # }
103
+ #
87
104
  # Encrypt message bodies at rest (ActiveRecord Encryption; requires
88
105
  # `bin/rails db:encryption:init`). Body search degrades when enabled.
89
106
  #
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-18 00:00:00.000000000 Z
10
+ date: 2026-10-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -138,18 +138,23 @@ files:
138
138
  - app/javascript/chats/dock_controller.js
139
139
  - app/javascript/chats/recovery.js
140
140
  - app/javascript/chats/refresh_inbox_controller.js
141
+ - app/javascript/chats/split_controller.js
141
142
  - app/javascript/chats/thread_controller.js
142
143
  - app/javascript/chats/unread_badge_controller.js
144
+ - app/views/chats/conversations/_conversation_pane.html.erb
143
145
  - app/views/chats/conversations/_conversation_row.html.erb
146
+ - app/views/chats/conversations/_empty_pane.html.erb
144
147
  - app/views/chats/conversations/_group.html.erb
145
148
  - app/views/chats/conversations/_history.html.erb
146
149
  - app/views/chats/conversations/_locked_composer.html.erb
147
150
  - app/views/chats/conversations/_messages_page.html.erb
148
151
  - app/views/chats/conversations/_read_state.html.erb
149
152
  - app/views/chats/conversations/_thread.html.erb
153
+ - app/views/chats/conversations/_thread_header.html.erb
150
154
  - app/views/chats/conversations/badge.turbo_stream.erb
151
155
  - app/views/chats/conversations/dock.html.erb
152
156
  - app/views/chats/conversations/index.html.erb
157
+ - app/views/chats/conversations/pane.html.erb
153
158
  - app/views/chats/conversations/refresh.turbo_stream.erb
154
159
  - app/views/chats/conversations/show.html.erb
155
160
  - app/views/chats/conversations/window.html.erb
@@ -168,7 +173,6 @@ files:
168
173
  - app/views/chats/messages/locked.turbo_stream.erb
169
174
  - app/views/chats/shared/_unread_badge.html.erb
170
175
  - app/views/chats/shared/_verified_badge.html.erb
171
- - chats-0.3.1.gem
172
176
  - config/importmap.rb
173
177
  - config/locales/en.yml
174
178
  - config/locales/es.yml
@@ -183,6 +187,10 @@ files:
183
187
  - docs/screenshots/dock-phone-panel.png
184
188
  - docs/screenshots/dock-phone-window.png
185
189
  - docs/screenshots/inbox.png
190
+ - docs/screenshots/split-inbox-empty.png
191
+ - docs/screenshots/split-inbox-phone-list.png
192
+ - docs/screenshots/split-inbox-phone-thread.png
193
+ - docs/screenshots/split-inbox.png
186
194
  - docs/screenshots/system-note-action.png
187
195
  - docs/screenshots/thread-actions-phone.png
188
196
  - gemfiles/rails_7.1.gemfile
data/chats-0.3.1.gem DELETED
Binary file