chats 0.1.1 → 0.3.1

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +233 -0
  3. data/README.md +214 -28
  4. data/app/assets/stylesheets/chats.css +86 -0
  5. data/app/controllers/chats/conversations_controller.rb +39 -42
  6. data/app/controllers/chats/messages_controller.rb +34 -0
  7. data/app/controllers/chats/reactions_controller.rb +14 -0
  8. data/app/helpers/chats/engine_helper.rb +113 -2
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
  11. data/app/views/chats/conversations/_group.html.erb +41 -0
  12. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  13. data/app/views/chats/conversations/index.html.erb +39 -9
  14. data/app/views/chats/conversations/show.html.erb +48 -3
  15. data/app/views/chats/messages/_composer.html.erb +4 -0
  16. data/app/views/chats/messages/_message.html.erb +29 -8
  17. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  18. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  19. data/config/importmap.rb +2 -1
  20. data/config/locales/en.yml +13 -0
  21. data/config/locales/es.yml +13 -0
  22. data/context7.json +4 -0
  23. data/docs/PRD.md +1 -1
  24. data/docs/campfire_review.md +1 -1
  25. data/gemfiles/rails_7.1.gemfile +1 -0
  26. data/gemfiles/rails_7.2.gemfile +1 -0
  27. data/gemfiles/rails_8.1.gemfile +1 -0
  28. data/lib/chats/configuration.rb +79 -1
  29. data/lib/chats/engine.rb +29 -7
  30. data/lib/chats/errors.rb +16 -0
  31. data/lib/chats/inbox.rb +303 -0
  32. data/lib/chats/inbox_group.rb +75 -0
  33. data/lib/chats/macros.rb +33 -1
  34. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  35. data/lib/chats/models/concerns/messager.rb +99 -2
  36. data/lib/chats/models/conversation.rb +78 -7
  37. data/lib/chats/models/message.rb +67 -3
  38. data/lib/chats/models/participant.rb +59 -0
  39. data/lib/chats/models/reaction.rb +5 -0
  40. data/lib/chats/subscribers.rb +156 -0
  41. data/lib/chats/version.rb +1 -1
  42. data/lib/chats.rb +122 -15
  43. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  44. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  45. data/lib/generators/chats/templates/initializer.rb +106 -14
  46. data/lib/generators/chats/upgrade_generator.rb +48 -0
  47. metadata +13 -2
@@ -11,6 +11,7 @@
11
11
  * :root {
12
12
  * --chats-accent: #facc15; /\* brand color: own bubbles, send button *\/
13
13
  * --chats-accent-contrast: #111827; /\* text on top of the accent *\/
14
+ * --chats-verified: #0284c7; /\* the "official account" badge *\/
14
15
  * }
15
16
  *
16
17
  * Want a completely different look? `rails g chats:views` ejects the
@@ -26,6 +27,7 @@
26
27
  --chats-border: rgba(0, 0, 0, 0.08);
27
28
  --chats-text: #111827;
28
29
  --chats-text-muted: #6b7280;
30
+ --chats-verified: #0284c7;
29
31
  --chats-bubble-bg: #f3f4f6;
30
32
  --chats-bubble-text: #111827;
31
33
  --chats-radius: 1.1rem;
@@ -79,6 +81,12 @@
79
81
  justify-content: space-between;
80
82
  gap: 0.75rem;
81
83
  }
84
+ .chats-row__name {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 0.25rem;
88
+ min-width: 0;
89
+ }
82
90
  .chats-row__title {
83
91
  font-weight: 650;
84
92
  white-space: nowrap;
@@ -147,6 +155,34 @@
147
155
  user-select: none;
148
156
  }
149
157
 
158
+ /* --- Verified messagers ------------------------------------------------------
159
+ *
160
+ * The "official account" mark (`acts_as_messager verified: true`). It sizes
161
+ * itself from the text it sits next to, so it reads right in an inbox row and
162
+ * in the thread header without either one hard-coding a pixel value. Recolour
163
+ * it with --chats-verified; replace the glyph with config.verified_badge.
164
+ *
165
+ * On #0284c7 rather than the more familiar #1d9bf0: the badge is a meaningful
166
+ * graphic, so WCAG 1.4.11 asks for 3:1 against what it sits on, and #1d9bf0
167
+ * is 3.00:1 on --chats-bg but only 2.73:1 on --chats-surface — which is the
168
+ * row hover background, so the mark failed exactly while someone was pointing
169
+ * at it. #0284c7 clears 3:1 on both (4.10 and 3.72) and on a dark ground too
170
+ * (4.33 on #111827), so a host that inverts the palette inherits a badge that
171
+ * still passes instead of one it has to remember to fix. Pinned by
172
+ * test/verified_badge_contrast_test.rb.
173
+ */
174
+ .chats-verified {
175
+ flex-shrink: 0;
176
+ display: inline-flex;
177
+ align-items: center;
178
+ color: var(--chats-verified);
179
+ line-height: 0;
180
+ }
181
+ .chats-verified__glyph {
182
+ width: 1em;
183
+ height: 1em;
184
+ }
185
+
150
186
  /* --- Thread -------------------------------------------------------------------- */
151
187
 
152
188
  .chats-thread {
@@ -179,6 +215,12 @@
179
215
  font-size: 1rem;
180
216
  font-weight: 750;
181
217
  margin: 0;
218
+ display: flex;
219
+ align-items: center;
220
+ gap: 0.25rem;
221
+ min-width: 0;
222
+ }
223
+ .chats-thread__name {
182
224
  white-space: nowrap;
183
225
  overflow: hidden;
184
226
  text-overflow: ellipsis;
@@ -816,3 +858,47 @@ turbo-frame[id^="chats_page_"] {
816
858
  border-radius: 0.5rem;
817
859
  }
818
860
  .chats-emoji:hover { background: var(--chats-surface); }
861
+
862
+ /* ---------------------------------------------------------------------------
863
+ 0.2.0 — stacked inbox rows, locked composers, signed messages
864
+ --------------------------------------------------------------------------- */
865
+
866
+ /* A stack (Chats::InboxGroup) reads as one row; the count line sits where a
867
+ single conversation shows its subject. */
868
+ .chats-row--group .chats-row__subject { font-weight: 600; }
869
+
870
+ /* The inbox filtered to one counterpart (?with=). */
871
+ .chats-inbox__filter {
872
+ display: flex;
873
+ align-items: baseline;
874
+ gap: 0.5rem;
875
+ margin: 0 0 0.5rem;
876
+ font-size: 0.85rem;
877
+ color: var(--chats-text-muted);
878
+ }
879
+ .chats-inbox__filter-clear { color: inherit; text-decoration: underline; }
880
+
881
+ /* "See all" back to the stack, under the thread title. */
882
+ .chats-thread__see-all { margin: 0; font-size: 0.72rem; }
883
+ .chats-thread__see-all-link { color: var(--chats-text-muted); text-decoration: underline; }
884
+
885
+ /* Who wrote it, when that isn't the seat it came from. */
886
+ .chats-message__signature {
887
+ margin-top: 0.25rem;
888
+ font-size: 0.7rem;
889
+ font-style: italic;
890
+ opacity: 0.75;
891
+ }
892
+
893
+ /* The composer, replaced by the reason the conversation is closed. Same box,
894
+ so the thread doesn't jump when a subject locks under an open composer. */
895
+ .chats-composer--locked {
896
+ display: block;
897
+ padding: 0.85rem 1rem;
898
+ text-align: center;
899
+ }
900
+ .chats-composer__locked-notice {
901
+ margin: 0;
902
+ font-size: 0.85rem;
903
+ color: var(--chats-text-muted);
904
+ }
@@ -5,16 +5,22 @@ module Chats
5
5
  # pages (create), and the per-member actions (read/typing/leave/mute).
6
6
  class ConversationsController < ApplicationController
7
7
  before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
8
+ helper_method :chats_counterpart
8
9
 
9
10
  # The inbox. Everything is preloaded/batched so rendering N rows costs a
10
11
  # constant number of queries (conversations + last messages + participants
11
12
  # + one grouped unread-count query — see Conversation.unread_counts_for).
13
+ # Rows are Conversation | InboxGroup: stacking, search and the `?with=`
14
+ # filter all live in Chats::Inbox, so this action stays three lines.
12
15
  def index
13
- @conversations = chats_current_messager.chats
14
- .includes(:last_message, :subject, participants: :messager)
15
- .limit(200)
16
- @conversations = apply_search(@conversations)
17
- @unread_counts = Chats::Conversation.unread_counts_for(chats_current_messager, @conversations)
16
+ @inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
17
+ @rows = @inbox.rows
18
+ @unread_counts = @inbox.unread_counts
19
+ # Back-compat for inboxes ejected under 0.1.x, which loop over
20
+ # @conversations: they keep rendering the flat list (no stacking, i.e.
21
+ # exactly what they rendered before). Re-eject or delete your copy to
22
+ # get the stacked rows.
23
+ @conversations = @inbox.conversations
18
24
  end
19
25
 
20
26
  # The thread. Renders the LATEST page of messages; older pages stream in
@@ -76,11 +82,18 @@ module Chats
76
82
  # A backlog deeper than one page would mean splicing an arbitrary
77
83
  # amount of history through surgical appends; a Turbo 8 page refresh
78
84
  # (morph + scroll preservation) re-renders the latest page + frame
79
- # chain correctly instead. Raw tag rather than `turbo_stream.refresh`
80
- # so we don't depend on turbo-rails ≥ 2.0 helpers.
85
+ # chain correctly instead.
86
+ #
87
+ # `render turbo_stream:`, NOT `render html: … content_type:` — the
88
+ # latter forces text/html and silently ignores the content type, so the
89
+ # body says <turbo-stream> while the response says it isn't one.
90
+ #
91
+ # `request_id: nil` on purpose: Turbo skips a refresh tagged with a
92
+ # request id it recognizes as its own, and this response is the answer
93
+ # to the client's OWN catch-up fetch — the one client that must not
94
+ # skip it.
81
95
  if @new_messages.size > Chats.config.messages_per_page
82
- render html: '<turbo-stream action="refresh"></turbo-stream>'.html_safe,
83
- content_type: "text/vnd.turbo-stream.html"
96
+ render turbo_stream: turbo_stream.refresh(request_id: nil)
84
97
  return
85
98
  end
86
99
 
@@ -156,43 +169,27 @@ module Chats
156
169
  GlobalID::Locator.locate_signed(sgid, for: purpose) || raise(ActiveRecord::RecordNotFound)
157
170
  end
158
171
 
159
- # Partial, case-insensitive matching across the inbox metadata users can
160
- # actually see: participant names, conversation titles, subject labels,
161
- # and message bodies. The inbox is capped at 200 rows, so metadata is
162
- # filtered portably in Ruby from the already-preloaded objects while the
163
- # potentially larger message-body set stays in SQL. No PostgreSQL-only
164
- # full-text dependency is needed for this scale.
165
- def apply_search(conversations)
166
- return conversations unless Chats.config.search
167
-
168
- query = params[:q].to_s.strip
169
- return conversations if query.empty?
170
-
171
- loaded = conversations.to_a
172
- normalized_query = query.downcase
173
- pattern = "%#{Chats::Conversation.sanitize_sql_like(query.downcase)}%"
174
- message_match_ids =
175
- if Chats.config.encrypt_messages
176
- []
177
- else
178
- Chats::Message.where(conversation_id: loaded.map(&:id), deleted_at: nil)
179
- .where("LOWER(chats_messages.body) LIKE ?", pattern)
180
- .distinct
181
- .pluck(:conversation_id)
182
- end
172
+ # `?with=<signed gid>` the inbox filtered to one counterpart (a stack's
173
+ # contents). Signed and purpose-scoped like every other polymorphic
174
+ # param the engine accepts; a tampered one is a plain 404.
175
+ def inbox_filter
176
+ return nil if params[:with].blank?
183
177
 
184
- loaded.select do |conversation|
185
- message_match_ids.include?(conversation.id) ||
186
- searchable_metadata(conversation).downcase.include?(normalized_query)
187
- end
178
+ messager = locate_signed!(params[:with], purpose: :chats_inbox_with)
179
+ raise ActiveRecord::RecordNotFound unless Chats.messager_class?(messager.class)
180
+
181
+ messager
188
182
  end
189
183
 
190
- def searchable_metadata(conversation)
191
- participant_names = conversation.participants.filter_map do |participant|
192
- Chats.display_name_for(participant.messager) if participant.active?
193
- end
184
+ # The other party of a direct thread (nil for groups) — the thread header
185
+ # names them, links to their profile, and decides whether to offer the
186
+ # "see all" link back to their stack. LAZY and memoized: a group thread
187
+ # never pays for it, and a direct one pays once no matter how many of
188
+ # those three things the rendered view asks for.
189
+ def chats_counterpart
190
+ return @chats_counterpart if defined?(@chats_counterpart)
194
191
 
195
- [conversation.title, conversation.subject_label, *participant_names].compact.join(" ")
192
+ @chats_counterpart = @conversation&.counterpart_for(chats_current_messager)
196
193
  end
197
194
  end
198
195
  end
@@ -6,6 +6,11 @@ module Chats
6
6
  before_action :set_conversation
7
7
  before_action :set_message, only: %i[show update destroy]
8
8
  before_action :require_ownership!, only: %i[update destroy]
9
+ # A locked conversation refuses every write, not just new messages.
10
+ # `create` is NOT in this list: its own validation produces the same
11
+ # response, and going through the model keeps the "locked since you
12
+ # opened the composer" race in one place.
13
+ before_action :refuse_when_locked!, only: %i[update destroy]
9
14
 
10
15
  # Per-sender send throttle via Rails 8's built-in controller rate
11
16
  # limiting (https://api.rubyonrails.org/classes/ActionController/RateLimiting.html).
@@ -40,6 +45,17 @@ module Chats
40
45
  format.turbo_stream
41
46
  format.html { redirect_to conversation_path(@conversation) }
42
47
  end
48
+ elsif locked?
49
+ # The subject closed the conversation (Chats::ChatSubject#
50
+ # chat_locked?) — possibly while this composer sat open. Swap the
51
+ # composer for the locked notice instead of flashing an error at
52
+ # someone whose screen is now lying to them. 422, never a raise.
53
+ respond_to do |format|
54
+ format.turbo_stream { render :locked, status: :unprocessable_entity }
55
+ format.html do
56
+ redirect_to conversation_path(@conversation), alert: @conversation.locked_notice
57
+ end
58
+ end
43
59
  else
44
60
  respond_to do |format|
45
61
  format.turbo_stream { render :errors, status: :unprocessable_entity }
@@ -85,6 +101,24 @@ module Chats
85
101
  @conversation = find_conversation(params[:conversation_id])
86
102
  end
87
103
 
104
+ # Did THIS save fail because the conversation is locked? Reads the error
105
+ # type, not the conversation, so a message that also failed validation
106
+ # for another reason still reports that reason.
107
+ def locked?
108
+ @message.errors.of_kind?(:base, :locked)
109
+ end
110
+
111
+ # Gate the action, explain it in place: the composer becomes the locked
112
+ # notice (422), or a plain redirect carrying the notice without Turbo.
113
+ def refuse_when_locked!
114
+ return unless @conversation.locked?
115
+
116
+ respond_to do |format|
117
+ format.turbo_stream { render :locked, status: :unprocessable_entity }
118
+ format.html { redirect_to conversation_path(@conversation), alert: @conversation.locked_notice }
119
+ end
120
+ end
121
+
88
122
  def set_message
89
123
  @message = @conversation.messages.find(params[:id])
90
124
  end
@@ -9,6 +9,7 @@ module Chats
9
9
  def create
10
10
  conversation = find_conversation(params[:conversation_id])
11
11
  message = conversation.messages.find(params[:message_id])
12
+ return refuse_when_locked(conversation) if conversation.locked?
12
13
 
13
14
  Chats::Reaction.toggle!(
14
15
  message: message,
@@ -29,5 +30,18 @@ module Chats
29
30
  rescue ActiveRecord::RecordInvalid
30
31
  head :unprocessable_entity
31
32
  end
33
+
34
+ private
35
+
36
+ # Same shape as the messages controller: swap the composer for the
37
+ # reason, 422, never an exception page.
38
+ def refuse_when_locked(conversation)
39
+ @conversation = conversation
40
+
41
+ respond_to do |format|
42
+ format.turbo_stream { render "chats/messages/locked", status: :unprocessable_entity }
43
+ format.html { redirect_to conversation_path(conversation), alert: conversation.locked_notice }
44
+ end
45
+ end
32
46
  end
33
47
  end
@@ -5,6 +5,17 @@ module Chats
5
5
  # HOST app's views (mixed into ActionView via the engine's on_load hook,
6
6
  # the same pattern the moderate gem uses for `report_link`).
7
7
  module EngineHelper
8
+ # Every slot the bundled views render, and the whole list of them. A
9
+ # host drops `app/views/chats/slots/_<name>.html.erb` in and it appears;
10
+ # a name that isn't here renders nothing.
11
+ SLOTS = %w[
12
+ inbox_top
13
+ inbox_empty
14
+ conversation_header_actions
15
+ locked_composer
16
+ message_meta
17
+ ].freeze
18
+
8
19
  # The "message this person" affordance for host pages — a listing, a
9
20
  # profile, an order. Renders nothing when there's no viewer, the viewer
10
21
  # IS the target, or policy/blocks forbid the pair, so it's always safe
@@ -117,8 +128,7 @@ module Chats
117
128
  # initials disc from the group name.
118
129
  def chats_conversation_avatar(conversation, viewer)
119
130
  if conversation.direct?
120
- other = conversation.other_participants(viewer).first
121
- chats_messager_avatar(other&.messager)
131
+ chats_messager_avatar(conversation.counterpart_for(viewer))
122
132
  else
123
133
  initials = conversation.title_for(viewer).split.first(2).map { |word| word[0] }.join.upcase
124
134
  tag.span(initials.presence || "👥", class: "chats-avatar chats-avatar--initials chats-avatar--group",
@@ -126,6 +136,107 @@ module Chats
126
136
  end
127
137
  end
128
138
 
139
+ # --- Slots ----------------------------------------------------------------
140
+ #
141
+ # Named extension points the bundled views render WHEN a partial exists
142
+ # at `chats/slots/_<name>`. Hosts (and engines mounted on top of chats,
143
+ # like support_desk) drop a file in and it appears; nobody has to eject
144
+ # a whole screen to add one row or one button. Absent slots cost one
145
+ # memoized template lookup and render nothing.
146
+ #
147
+ # app/views/chats/slots/_inbox_top.html.erb
148
+ #
149
+ # The slots: inbox_top, inbox_empty, conversation_header_actions,
150
+ # locked_composer, message_meta.
151
+ def chats_slot(name, **locals)
152
+ return unless chats_slot?(name)
153
+
154
+ render(partial: "chats/slots/#{name}", locals: locals)
155
+ end
156
+
157
+ # Whether a slot partial exists. Memoized per view instance, so a slot
158
+ # rendered inside a collection costs ONE lookup per request, not one per
159
+ # row. Anything outside SLOTS is ignored rather than looked up: the slot
160
+ # names are a contract, and a typo should render nothing instead of
161
+ # quietly becoming a new extension point nobody documented.
162
+ def chats_slot?(name)
163
+ key = name.to_s
164
+ return false unless Chats::EngineHelper::SLOTS.include?(key)
165
+
166
+ @chats_slots ||= {}
167
+ return @chats_slots[key] if @chats_slots.key?(key)
168
+
169
+ @chats_slots[key] = lookup_context.exists?("chats/slots/#{key}", [], true)
170
+ end
171
+
172
+ # --- Messager display -----------------------------------------------------
173
+
174
+ # Whether block/report affordances apply to this messager. False for
175
+ # `acts_as_messager blockable: false` (a support desk, a bot) — the
176
+ # bundled views hide the affordance instead of asking hosts to branch on
177
+ # class.
178
+ def chats_blockable?(messager)
179
+ Chats.blockable?(messager)
180
+ end
181
+
182
+ # A messager's profile URL per `config.messager_url`, or nil.
183
+ def chats_messager_url(messager)
184
+ Chats.messager_url_for(messager)
185
+ end
186
+
187
+ # A messager's name, linked to their profile when `config.messager_url`
188
+ # gives one and plain text when it doesn't — so the gem never renders a
189
+ # dead anchor or assumes a `user_path` exists.
190
+ def chats_messager_name(messager, css_class: nil)
191
+ name = Chats.display_name_for(messager)
192
+ url = chats_messager_url(messager)
193
+
194
+ url.present? ? link_to(name, url, class: css_class) : tag.span(name, class: css_class)
195
+ end
196
+
197
+ # The "official account" mark for a messager declared `acts_as_messager
198
+ # verified: true`, and NIL for everyone else — so any view, bundled or
199
+ # host, can drop it next to a name unconditionally:
200
+ #
201
+ # <%= chats_messager_name(author) %><%= chats_verified_badge(author) %>
202
+ #
203
+ # `config.verified_badge` swaps the glyph for the host's own design
204
+ # system; the default renders `chats/shared/_verified_badge`, whose
205
+ # colour is the `--chats-verified` CSS variable.
206
+ def chats_verified_badge(messager)
207
+ return unless Chats.verified?(messager)
208
+
209
+ custom = Chats.config.verified_badge
210
+ return custom.call(messager) if custom
211
+
212
+ render(partial: "chats/shared/verified_badge", locals: { messager: messager })
213
+ end
214
+
215
+ # The signature line under a signed message ("— Lucía G."), or nil.
216
+ def chats_message_signature(message)
217
+ Chats.message_signature_for(message)
218
+ end
219
+
220
+ # --- Grouped inbox rows ---------------------------------------------------
221
+
222
+ # Where a stacked inbox row goes: straight to the thread when the stack
223
+ # holds exactly one conversation, otherwise to the stack itself.
224
+ def chats_group_path(group)
225
+ return chats_routes.conversation_path(group.conversation) if group.single?
226
+
227
+ chats_group_path_for(group.messager)
228
+ end
229
+
230
+ # The stack list for a messager: the host's `group_path:` callable when
231
+ # `acts_as_messager` declared one (support_desk points it at its own
232
+ # screen), else chats' own filtered inbox.
233
+ def chats_group_path_for(messager)
234
+ custom = messager.class.try(:chat_group_path)
235
+ path = custom&.call(chats_viewer)
236
+
237
+ path.presence || chats_routes.conversations_path(with: Chats.inbox_with_sgid(messager))
238
+ end
239
+
129
240
  # The gem's bundled stylesheet (CSS-variable themed — see chats.css).
130
241
  # Called from the engine's own views; hosts that eject + restyle the
131
242
  # views with their own framework simply don't include it.
@@ -0,0 +1,86 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // chats--refresh-inbox — heals the inbox after a MISSED broadcast.
4
+ //
5
+ // The inbox updates via Turbo 8 page-refresh broadcasts
6
+ // (Chats::Broadcasts.refresh_inbox_of → broadcast_refresh_later_to). Action
7
+ // Cable has no replay: a refresh broadcast sent while this client's socket
8
+ // was down (backgrounded tab/app, network blip, laptop asleep) is gone
9
+ // forever, and the inbox silently sits at its last render until the user
10
+ // happens to navigate. This controller re-runs the SAME page refresh on the
11
+ // two moments we might have missed one — the socket reconnecting, and the
12
+ // tab becoming visible again after long enough that the socket was reaped.
13
+ //
14
+ // It deliberately reuses chats--thread's reconnect/visibility detection (the
15
+ // gem-native approach: observe the <turbo-cable-stream-source> `connected`
16
+ // attribute rather than stand up a dedicated HeartbeatChannel — the attribute
17
+ // IS the heartbeat). The difference is the recovery action: the thread does an
18
+ // HTTP `?since=` delta because it patches messages surgically, whereas the
19
+ // inbox already broadcasts whole-page refreshes, so the catch-up is just
20
+ // `Turbo.session.refresh()` — idempotent, morphing, scroll-preserving.
21
+ //
22
+ // Registered automatically under the identifier "chats--refresh-inbox" via
23
+ // the engine's importmap pin (see config/importmap.rb + Chats::Engine).
24
+ //
25
+ // Doctrine: Turbo Stream delivery is a best-effort enhancement; the page must
26
+ // be correct without it. https://turbo.hotwired.dev/handbook/streams
27
+ const REFRESH_AFTER_HIDDEN_MS = 60_000
28
+
29
+ export default class extends Controller {
30
+ connect() {
31
+ this.streamDropped = false
32
+ this.hiddenAt = null
33
+ document.addEventListener("visibilitychange", this.visibilityChanged)
34
+ this.watchStreamSource()
35
+ }
36
+
37
+ disconnect() {
38
+ document.removeEventListener("visibilitychange", this.visibilityChanged)
39
+ this.sourceObserver?.disconnect()
40
+ }
41
+
42
+ visibilityChanged = () => {
43
+ if (document.visibilityState === "visible") {
44
+ // Hidden long enough that the socket may have been reaped → catch up
45
+ // on whatever the missed broadcasts would have refreshed.
46
+ if (this.hiddenAt && Date.now() - this.hiddenAt > REFRESH_AFTER_HIDDEN_MS) {
47
+ this.refresh()
48
+ }
49
+ this.hiddenAt = null
50
+ } else {
51
+ this.hiddenAt = Date.now()
52
+ }
53
+ }
54
+
55
+ // Reconnect detection without a dedicated cable channel: turbo-rails toggles
56
+ // a `connected` attribute on <turbo-cable-stream-source> as the Action Cable
57
+ // subscription confirms/drops, so observing it IS the heartbeat. A refresh
58
+ // that morphs the page can momentarily strip the attribute — harmless here:
59
+ // it only arms `streamDropped`, and the next genuine reconnect does one
60
+ // (cheap, idempotent) catch-up refresh.
61
+ watchStreamSource() {
62
+ const source = this.element.querySelector("turbo-cable-stream-source")
63
+ if (!source || typeof MutationObserver === "undefined") return
64
+
65
+ this.sourceObserver = new MutationObserver(() => {
66
+ const connected = source.hasAttribute("connected")
67
+ if (connected && this.streamDropped) {
68
+ this.streamDropped = false
69
+ this.refresh()
70
+ } else if (!connected) {
71
+ this.streamDropped = true
72
+ }
73
+ })
74
+ this.sourceObserver.observe(source, { attributes: true, attributeFilter: ["connected"] })
75
+ }
76
+
77
+ refresh() {
78
+ if (document.visibilityState !== "visible") return
79
+ // Don't morph the list out from under someone typing in the search box —
80
+ // the next trigger (or their own submit) will catch it up.
81
+ const active = document.activeElement
82
+ if (active && active.matches("input, textarea, select")) return
83
+
84
+ window.Turbo?.session?.refresh(document.baseURI)
85
+ }
86
+ }
@@ -7,7 +7,13 @@
7
7
 
8
8
  <span class="chats-row__body">
9
9
  <span class="chats-row__top">
10
- <span class="chats-row__title"><%= conversation.title_for(viewer) %></span>
10
+ <%# The badge sits OUTSIDE the ellipsizing title so a long name
11
+ truncates without ever eating the mark. Nil for an ordinary
12
+ counterpart and for groups, so the row is unchanged. %>
13
+ <span class="chats-row__name">
14
+ <span class="chats-row__title"><%= conversation.title_for(viewer) %></span>
15
+ <%= chats_verified_badge(conversation.counterpart_for(viewer)) %>
16
+ </span>
11
17
  <time class="chats-row__time" datetime="<%= conversation.last_message_at&.iso8601 %>">
12
18
  <%= chats_timestamp(conversation.last_message_at) %>
13
19
  </time>
@@ -0,0 +1,41 @@
1
+ <%# One STACKED inbox row: every direct conversation the viewer has with a
2
+ `acts_as_messager inbox: :grouped` counterpart (a support desk, a
3
+ storefront), folded into a single line — their avatar and name, the
4
+ freshest message across the stack, and the aggregate unread badge.
5
+
6
+ A stack of one links straight to that thread (and the thread offers a
7
+ "see all" link back here); a deeper stack opens the stack list, which is
8
+ either the host's `group_path:` or chats' own filtered inbox. %>
9
+ <li class="chats-row chats-row--group <%= "chats-row--unread" if group.unread? %>"
10
+ id="<%= group.dom_id %>">
11
+ <%= link_to chats_group_path(group), class: "chats-row__link" do %>
12
+ <%= chats_messager_avatar(group.messager) %>
13
+
14
+ <span class="chats-row__body">
15
+ <span class="chats-row__top">
16
+ <span class="chats-row__name">
17
+ <span class="chats-row__title"><%= group.title_for(viewer) %></span>
18
+ <%= chats_verified_badge(group.messager) %>
19
+ </span>
20
+ <time class="chats-row__time" datetime="<%= group.last_message_at&.iso8601 %>">
21
+ <%= chats_timestamp(group.last_message_at) %>
22
+ </time>
23
+ </span>
24
+
25
+ <% if group.open_count > 1 %>
26
+ <span class="chats-row__subject"><%= t("chats.inbox.group_count", count: group.open_count) %></span>
27
+ <% elsif group.conversation&.subject_label %>
28
+ <span class="chats-row__subject"><%= group.conversation.subject_label %></span>
29
+ <% end %>
30
+
31
+ <span class="chats-row__bottom">
32
+ <span class="chats-row__preview">
33
+ <%= group.conversation ? chats_preview_for(group.conversation, viewer) : "" %>
34
+ </span>
35
+ <% if group.unread? %>
36
+ <span class="chats-badge"><%= group.unread_count > 99 ? "99+" : group.unread_count %></span>
37
+ <% end %>
38
+ </span>
39
+ </span>
40
+ <% end %>
41
+ </li>
@@ -0,0 +1,15 @@
1
+ <%# What sits where the composer would be when the conversation's SUBJECT
2
+ has locked it (Chats::ChatSubject#chat_locked?). It carries the same DOM
3
+ id as the composer form, so MessagesController#create can swap one for
4
+ the other with a single Turbo replace when a thread closes under an open
5
+ composer.
6
+
7
+ The `locked_composer` slot replaces the BODY (a reopen button, a link to
8
+ the closed ticket…) while keeping the id contract intact. %>
9
+ <div id="<%= dom_id(conversation, :composer) %>" class="chats-composer chats-composer--locked">
10
+ <% if chats_slot?(:locked_composer) %>
11
+ <%= chats_slot :locked_composer, conversation: conversation, notice: conversation.locked_notice %>
12
+ <% else %>
13
+ <p class="chats-composer__locked-notice"><%= conversation.locked_notice %></p>
14
+ <% end %>
15
+ </div>