chats 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +163 -28
  4. data/app/assets/stylesheets/chats.css +44 -0
  5. data/app/controllers/chats/conversations_controller.rb +43 -43
  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 +94 -0
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_group.html.erb +38 -0
  11. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  12. data/app/views/chats/conversations/index.html.erb +39 -9
  13. data/app/views/chats/conversations/show.html.erb +44 -3
  14. data/app/views/chats/messages/_composer.html.erb +4 -0
  15. data/app/views/chats/messages/_message.html.erb +29 -8
  16. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  17. data/config/importmap.rb +2 -1
  18. data/config/locales/en.yml +11 -0
  19. data/config/locales/es.yml +11 -0
  20. data/context7.json +4 -0
  21. data/docs/PRD.md +1 -1
  22. data/docs/campfire_review.md +1 -1
  23. data/gemfiles/rails_7.1.gemfile +1 -0
  24. data/gemfiles/rails_7.2.gemfile +1 -0
  25. data/gemfiles/rails_8.1.gemfile +1 -0
  26. data/lib/chats/configuration.rb +63 -1
  27. data/lib/chats/engine.rb +29 -7
  28. data/lib/chats/errors.rb +16 -0
  29. data/lib/chats/inbox.rb +303 -0
  30. data/lib/chats/inbox_group.rb +75 -0
  31. data/lib/chats/macros.rb +24 -1
  32. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  33. data/lib/chats/models/concerns/messager.rb +81 -2
  34. data/lib/chats/models/conversation.rb +59 -5
  35. data/lib/chats/models/message.rb +67 -3
  36. data/lib/chats/models/participant.rb +59 -0
  37. data/lib/chats/models/reaction.rb +5 -0
  38. data/lib/chats/subscribers.rb +156 -0
  39. data/lib/chats/version.rb +1 -1
  40. data/lib/chats.rb +114 -15
  41. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  42. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  43. data/lib/generators/chats/templates/initializer.rb +95 -14
  44. data/lib/generators/chats/upgrade_generator.rb +48 -0
  45. metadata +12 -2
@@ -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
@@ -126,6 +137,89 @@ module Chats
126
137
  end
127
138
  end
128
139
 
140
+ # --- Slots ----------------------------------------------------------------
141
+ #
142
+ # Named extension points the bundled views render WHEN a partial exists
143
+ # at `chats/slots/_<name>`. Hosts (and engines mounted on top of chats,
144
+ # like support_desk) drop a file in and it appears; nobody has to eject
145
+ # a whole screen to add one row or one button. Absent slots cost one
146
+ # memoized template lookup and render nothing.
147
+ #
148
+ # app/views/chats/slots/_inbox_top.html.erb
149
+ #
150
+ # The slots: inbox_top, inbox_empty, conversation_header_actions,
151
+ # locked_composer, message_meta.
152
+ def chats_slot(name, **locals)
153
+ return unless chats_slot?(name)
154
+
155
+ render(partial: "chats/slots/#{name}", locals: locals)
156
+ end
157
+
158
+ # Whether a slot partial exists. Memoized per view instance, so a slot
159
+ # rendered inside a collection costs ONE lookup per request, not one per
160
+ # row. Anything outside SLOTS is ignored rather than looked up: the slot
161
+ # names are a contract, and a typo should render nothing instead of
162
+ # quietly becoming a new extension point nobody documented.
163
+ def chats_slot?(name)
164
+ key = name.to_s
165
+ return false unless Chats::EngineHelper::SLOTS.include?(key)
166
+
167
+ @chats_slots ||= {}
168
+ return @chats_slots[key] if @chats_slots.key?(key)
169
+
170
+ @chats_slots[key] = lookup_context.exists?("chats/slots/#{key}", [], true)
171
+ end
172
+
173
+ # --- Messager display -----------------------------------------------------
174
+
175
+ # Whether block/report affordances apply to this messager. False for
176
+ # `acts_as_messager blockable: false` (a support desk, a bot) — the
177
+ # bundled views hide the affordance instead of asking hosts to branch on
178
+ # class.
179
+ def chats_blockable?(messager)
180
+ Chats.blockable?(messager)
181
+ end
182
+
183
+ # A messager's profile URL per `config.messager_url`, or nil.
184
+ def chats_messager_url(messager)
185
+ Chats.messager_url_for(messager)
186
+ end
187
+
188
+ # A messager's name, linked to their profile when `config.messager_url`
189
+ # gives one and plain text when it doesn't — so the gem never renders a
190
+ # dead anchor or assumes a `user_path` exists.
191
+ def chats_messager_name(messager, css_class: nil)
192
+ name = Chats.display_name_for(messager)
193
+ url = chats_messager_url(messager)
194
+
195
+ url.present? ? link_to(name, url, class: css_class) : tag.span(name, class: css_class)
196
+ end
197
+
198
+ # The signature line under a signed message ("— Lucía G."), or nil.
199
+ def chats_message_signature(message)
200
+ Chats.message_signature_for(message)
201
+ end
202
+
203
+ # --- Grouped inbox rows ---------------------------------------------------
204
+
205
+ # Where a stacked inbox row goes: straight to the thread when the stack
206
+ # holds exactly one conversation, otherwise to the stack itself.
207
+ def chats_group_path(group)
208
+ return chats_routes.conversation_path(group.conversation) if group.single?
209
+
210
+ chats_group_path_for(group.messager)
211
+ end
212
+
213
+ # The stack list for a messager: the host's `group_path:` callable when
214
+ # `acts_as_messager` declared one (support_desk points it at its own
215
+ # screen), else chats' own filtered inbox.
216
+ def chats_group_path_for(messager)
217
+ custom = messager.class.try(:chat_group_path)
218
+ path = custom&.call(chats_viewer)
219
+
220
+ path.presence || chats_routes.conversations_path(with: Chats.inbox_with_sgid(messager))
221
+ end
222
+
129
223
  # The gem's bundled stylesheet (CSS-variable themed — see chats.css).
130
224
  # Called from the engine's own views; hosts that eject + restyle the
131
225
  # 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
+ }
@@ -0,0 +1,38 @@
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__title"><%= group.title_for(viewer) %></span>
17
+ <time class="chats-row__time" datetime="<%= group.last_message_at&.iso8601 %>">
18
+ <%= chats_timestamp(group.last_message_at) %>
19
+ </time>
20
+ </span>
21
+
22
+ <% if group.open_count > 1 %>
23
+ <span class="chats-row__subject"><%= t("chats.inbox.group_count", count: group.open_count) %></span>
24
+ <% elsif group.conversation&.subject_label %>
25
+ <span class="chats-row__subject"><%= group.conversation.subject_label %></span>
26
+ <% end %>
27
+
28
+ <span class="chats-row__bottom">
29
+ <span class="chats-row__preview">
30
+ <%= group.conversation ? chats_preview_for(group.conversation, viewer) : "" %>
31
+ </span>
32
+ <% if group.unread? %>
33
+ <span class="chats-badge"><%= group.unread_count > 99 ? "99+" : group.unread_count %></span>
34
+ <% end %>
35
+ </span>
36
+ </span>
37
+ <% end %>
38
+ </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>
@@ -1,16 +1,37 @@
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
+ Rows come from Chats::Inbox and are either a Chats::Conversation or a
7
+ Chats::InboxGroup (every direct thread with a `inbox: :grouped` messager,
8
+ stacked into one row).
9
+
10
+ The chats--refresh-inbox wrapper heals a MISSED refresh broadcast: Action
11
+ Cable has no replay, so a broadcast sent while the socket was down (tab
12
+ backgrounded, network blip) would leave the inbox stale until the user
13
+ navigated. The controller re-runs the same page refresh on cable reconnect
14
+ and on return-to-visible. It needs to wrap the <turbo-cable-stream-source>
15
+ that `turbo_stream_from` renders so it can observe its `connected`
16
+ attribute as the heartbeat. %>
5
17
  <%= chats_styles %>
6
- <%= turbo_stream_from chats_current_messager, :chats_inbox %>
18
+ <div data-controller="chats--refresh-inbox">
19
+ <%= turbo_stream_from chats_current_messager, :chats_inbox %>
20
+ </div>
7
21
 
8
22
  <div class="chats chats-inbox">
9
23
  <header class="chats-inbox__header">
10
24
  <h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
11
25
  </header>
12
26
 
13
- <% if Chats.config.search %>
27
+ <% if @inbox.filtered? %>
28
+ <p class="chats-inbox__filter">
29
+ <%= t("chats.inbox.filtered_by", name: Chats.display_name_for(@inbox.with)) %>
30
+ <%= link_to t("chats.inbox.clear_filter"), conversations_path, class: "chats-inbox__filter-clear" %>
31
+ </p>
32
+ <% end %>
33
+
34
+ <% if Chats.config.search && !@inbox.filtered? %>
14
35
  <%= form_with url: conversations_path,
15
36
  method: :get,
16
37
  class: "chats-search",
@@ -29,13 +50,21 @@
29
50
  <% end %>
30
51
 
31
52
  <%= turbo_frame_tag "chats_inbox_results", target: "_top" do %>
32
- <% if @conversations.any? %>
53
+ <%# Slot: anything the host wants above the first row — a support door,
54
+ an announcement, a filter bar. Renders only when the partial exists. %>
55
+ <%= chats_slot :inbox_top, viewer: chats_current_messager, inbox: @inbox %>
56
+
57
+ <% if @rows.any? %>
33
58
  <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) %>
59
+ <% @rows.each do |row| %>
60
+ <% if row.is_a?(Chats::InboxGroup) %>
61
+ <%= render "chats/conversations/group", group: row, viewer: chats_current_messager %>
62
+ <% else %>
63
+ <%= render "chats/conversations/conversation_row",
64
+ conversation: row,
65
+ viewer: chats_current_messager,
66
+ unread_count: @unread_counts.fetch(row.id, 0) %>
67
+ <% end %>
39
68
  <% end %>
40
69
  </ul>
41
70
  <% elsif params[:q].present? %>
@@ -48,6 +77,7 @@
48
77
  <span class="chats-empty__icon" aria-hidden="true">💬</span>
49
78
  <p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
50
79
  <p class="chats-empty__hint"><%= t("chats.inbox.empty_hint") %></p>
80
+ <%= chats_slot :inbox_empty, viewer: chats_current_messager, inbox: @inbox %>
51
81
  </div>
52
82
  <% end %>
53
83
  <% end %>
@@ -19,6 +19,10 @@
19
19
  "chats--thread-refresh-url-value": refresh_conversation_path(@conversation),
20
20
  "chats--thread-thread-url-value": conversation_path(@conversation),
21
21
  "chats--thread-copied-label-value": t("chats.message.copied"),
22
+ # A plain data attribute (not a Stimulus value): host CSS/JS keys its
23
+ # own block/report affordances off it. False for a messager declared
24
+ # `acts_as_messager blockable: false`.
25
+ "chats-blockable": chats_blockable?(chats_counterpart),
22
26
  action: "pointerdown->chats--thread#pressStart pointermove->chats--thread#pressMove " \
23
27
  "pointerup->chats--thread#pressEnd pointercancel->chats--thread#pressCancel " \
24
28
  "contextmenu->chats--thread#contextMenu"
@@ -31,13 +35,33 @@
31
35
  <span aria-hidden="true">‹</span>
32
36
  <% end %>
33
37
 
34
- <%= chats_conversation_avatar(@conversation, chats_current_messager) %>
38
+ <%# The counterpart is already resolved for the title, the profile link
39
+ and the stack link — reuse it here instead of asking again. %>
40
+ <% if chats_counterpart %>
41
+ <%= chats_messager_avatar(chats_counterpart) %>
42
+ <% else %>
43
+ <%= chats_conversation_avatar(@conversation, chats_current_messager) %>
44
+ <% end %>
35
45
 
36
46
  <div class="chats-thread__identity">
37
- <h1 class="chats-thread__title"><%= @conversation.title_for(chats_current_messager) %></h1>
47
+ <h1 class="chats-thread__title">
48
+ <% if chats_counterpart %>
49
+ <%= chats_messager_name(chats_counterpart) %>
50
+ <% else %>
51
+ <%= @conversation.title_for(chats_current_messager) %>
52
+ <% end %>
53
+ </h1>
38
54
  <% if @conversation.subject_label %>
39
55
  <p class="chats-thread__subject"><%= @conversation.subject_label %></p>
40
56
  <% end %>
57
+ <%# A stacked counterpart's thread is one of many: offer the way back
58
+ to the whole stack (chats' filtered inbox, or the host's own
59
+ screen via `group_path:`). %>
60
+ <% if Chats.grouped_inbox?(chats_counterpart) %>
61
+ <p class="chats-thread__see-all">
62
+ <%= link_to t("chats.thread.see_all"), chats_group_path_for(chats_counterpart), class: "chats-thread__see-all-link" %>
63
+ </p>
64
+ <% end %>
41
65
  </div>
42
66
 
43
67
  <details class="chats-menu">
@@ -51,6 +75,15 @@
51
75
  <% if @conversation.group? %>
52
76
  <%= button_to t("chats.thread.leave"), leave_conversation_path(@conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
53
77
  <% end %>
78
+ <%# Slot: the host's own thread actions (block, report, archive…).
79
+ `blockable` is false for messagers declared
80
+ `acts_as_messager blockable: false`, so safety affordances
81
+ disappear against a support desk without any class check. %>
82
+ <%= chats_slot :conversation_header_actions,
83
+ conversation: @conversation,
84
+ viewer: chats_current_messager,
85
+ counterpart: chats_counterpart,
86
+ blockable: chats_blockable?(chats_counterpart) %>
54
87
  </div>
55
88
  </details>
56
89
  </header>
@@ -133,5 +166,13 @@
133
166
  </div>
134
167
  </div>
135
168
 
136
- <%= render "chats/messages/composer", conversation: @conversation %>
169
+ <%# A locked conversation (its SUBJECT says so — Chats::ChatSubject#
170
+ chat_locked?) keeps its whole history readable and swaps the composer
171
+ for the reason it's closed. Never a hidden screen: gate the action,
172
+ explain it in place. %>
173
+ <% if @conversation.locked? %>
174
+ <%= render "chats/conversations/locked_composer", conversation: @conversation %>
175
+ <% else %>
176
+ <%= render "chats/messages/composer", conversation: @conversation %>
177
+ <% end %>
137
178
  <% end %>
@@ -2,8 +2,12 @@
2
2
  the response — instant, no cable round-trip needed); the chats--composer
3
3
  controller adds autosize, desktop Enter-to-send, throttled typing pings,
4
4
  and reset-on-success. Plain form POST still works with JS disabled. %>
5
+ <%# id: the swap target — a conversation that locks while this form is open
6
+ gets the locked notice rendered straight over it (see
7
+ chats/messages/locked.turbo_stream.erb). %>
5
8
  <%= form_with model: Chats::Message.new,
6
9
  url: conversation_messages_path(conversation),
10
+ id: dom_id(conversation, :composer),
7
11
  class: "chats-composer",
8
12
  data: {
9
13
  controller: "chats--composer",
@@ -27,7 +27,7 @@
27
27
  <% else %>
28
28
  <div class="chats-message__content">
29
29
  <% if message.conversation.group? && message.sender %>
30
- <span class="chats-message__sender"><%= Chats.display_name_for(message.sender) %></span>
30
+ <%= chats_messager_name(message.sender, css_class: "chats-message__sender") %>
31
31
  <% end %>
32
32
 
33
33
  <div class="chats-message__bubble-row">
@@ -76,11 +76,20 @@
76
76
  <% end %>
77
77
  <% end %>
78
78
 
79
+ <%# Signature: who WROTE this, when that isn't who it was sent
80
+ from — an agent answering from a shared desk seat. The sender
81
+ stays the conversation identity; this line keeps the human
82
+ visible. See Chats::Message#signed?. %>
83
+ <% if (signature = chats_message_signature(message)) %>
84
+ <div class="chats-message__signature" data-chats-message-signature><%= signature %></div>
85
+ <% end %>
86
+
79
87
  <span class="chats-message__meta">
80
88
  <% if message.edited? && !message.deleted? %>
81
89
  <span class="chats-message__edited"><%= t("chats.message.edited") %></span>
82
90
  <% end %>
83
91
  <time datetime="<%= message.created_at.iso8601 %>"><%= message.created_at.in_time_zone.strftime("%H:%M") %></time>
92
+ <%= chats_slot :message_meta, message: message %>
84
93
  <% if Chats.config.read_receipts %>
85
94
  <span class="chats-message__receipt"
86
95
  data-chats-message-receipt
@@ -95,6 +104,12 @@
95
104
  </div>
96
105
 
97
106
  <% unless message.deleted? %>
107
+ <%# A locked conversation (its SUBJECT says so) refuses every write —
108
+ new messages, edits, deletes and reactions alike. The server
109
+ enforces it; here we stop OFFERING what would only 422: existing
110
+ reactions still render, as plain counts instead of toggles, and
111
+ the long-press menu keeps only Copy. %>
112
+ <% locked = message.conversation.locked? %>
98
113
  <% reactions = Chats.config.reactions ? Chats::Reaction.summary_for(message) : [] %>
99
114
  <% if reactions.any? %>
100
115
  <div class="chats-message__reactions">
@@ -103,10 +118,16 @@
103
118
  through the HOST's renderer where engine helpers don't exist
104
119
  unqualified. See EngineHelper#chats_routes. %>
105
120
  <% reactions.each do |emoji, count| %>
106
- <%= button_to chats_routes.conversation_message_reactions_path(message.conversation, message),
107
- method: :post, params: { emoji: emoji },
108
- class: "chats-reaction", "aria-label": t("chats.message.toggle_reaction", emoji: emoji) do %>
109
- <%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
121
+ <% if locked %>
122
+ <span class="chats-reaction chats-reaction--locked">
123
+ <%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
124
+ </span>
125
+ <% else %>
126
+ <%= button_to chats_routes.conversation_message_reactions_path(message.conversation, message),
127
+ method: :post, params: { emoji: emoji },
128
+ class: "chats-reaction", "aria-label": t("chats.message.toggle_reaction", emoji: emoji) do %>
129
+ <%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
130
+ <% end %>
110
131
  <% end %>
111
132
  <% end %>
112
133
  </div>
@@ -122,7 +143,7 @@
122
143
  button_to forms keep working when cloned: the CSRF token is
123
144
  baked in at render time. %>
124
145
  <template data-chats-message-menu>
125
- <% if Chats.config.reactions %>
146
+ <% if Chats.config.reactions && !locked %>
126
147
  <div class="chats-popup__reactions" role="group" aria-label="<%= t("chats.message.react") %>">
127
148
  <% %w[👍 ❤️ 😂 😮 😢 🙏].each do |emoji| %>
128
149
  <%= button_to emoji, chats_routes.conversation_message_reactions_path(message.conversation, message),
@@ -137,12 +158,12 @@
137
158
  <%= t("chats.message.copy") %>
138
159
  </button>
139
160
  <% end %>
140
- <% if Chats.config.editing && message.body.present? %>
161
+ <% if Chats.config.editing && message.body.present? && !locked %>
141
162
  <button type="button" class="chats-popup__item" role="menuitem" data-chats-action="edit" data-chats-own-only>
142
163
  <%= t("chats.message.edit") %>
143
164
  </button>
144
165
  <% end %>
145
- <% if Chats.config.deletion %>
166
+ <% if Chats.config.deletion && !locked %>
146
167
  <%= button_to t("chats.message.delete"),
147
168
  chats_routes.conversation_message_path(message.conversation, message),
148
169
  method: :delete,
@@ -0,0 +1,6 @@
1
+ <%# The send landed on a conversation whose subject has since locked it.
2
+ Replace the composer with the locked notice (422) so the screen stops
3
+ lying to whoever had it open, and clear any stale error text. %>
4
+ <%= turbo_stream.replace dom_id(@conversation, :composer) do %>
5
+ <%= render "chats/conversations/locked_composer", conversation: @conversation %>
6
+ <% end %>
data/config/importmap.rb CHANGED
@@ -8,9 +8,10 @@
8
8
  # "chats--composer", etc. with ZERO host JavaScript changes. (stimulus-rails,
9
9
  # app/assets/javascripts/stimulus-loading.js, registerControllerFromPath.)
10
10
  #
11
- # Hosts can override either controller by pinning the same key themselves —
11
+ # Hosts can override any controller by pinning the same key themselves —
12
12
  # the engine's importmap is drawn FIRST (unshifted in Chats::Engine), and
13
13
  # importmap-rails resolves duplicate pins last-wins.
14
14
  pin "controllers/chats/thread_controller", to: "chats/thread_controller.js"
15
15
  pin "controllers/chats/composer_controller", to: "chats/composer_controller.js"
16
16
  pin "controllers/chats/debounced_submit_controller", to: "chats/debounced_submit_controller.js"
17
+ pin "controllers/chats/refresh_inbox_controller", to: "chats/refresh_inbox_controller.js"
@@ -8,6 +8,11 @@ en:
8
8
  no_results_title: "No results"
9
9
  no_results_hint: "Nothing matched “%{query}”."
10
10
  no_messages: "No messages yet"
11
+ filtered_by: "Conversations with %{name}"
12
+ clear_filter: "Show all conversations"
13
+ group_count:
14
+ one: "1 conversation"
15
+ other: "%{count} conversations"
11
16
  you_prefix: "You:"
12
17
  thread:
13
18
  back: "Back"
@@ -22,6 +27,7 @@ en:
22
27
  yesterday: "Yesterday"
23
28
  typing_suffix: "is typing…"
24
29
  new_messages: "New messages"
30
+ see_all: "See all"
25
31
  conversation:
26
32
  empty_title: "Conversation"
27
33
  message:
@@ -30,6 +36,7 @@ en:
30
36
  copy: "Copy"
31
37
  copied: "Copied!"
32
38
  attachment: "Photo"
39
+ signature: "— %{name}"
33
40
  close_attachment: "Close photo"
34
41
  edit: "Edit"
35
42
  delete: "Delete"
@@ -44,6 +51,7 @@ en:
44
51
  placeholder: "Write a message…"
45
52
  send: "Send"
46
53
  attach: "Attach images"
54
+ locked: "This conversation is closed."
47
55
  buttons:
48
56
  chat: "Message"
49
57
  flashes:
@@ -67,7 +75,10 @@ en:
67
75
  chats/message:
68
76
  attributes:
69
77
  base:
78
+ locked: "This conversation is closed."
70
79
  blocked: "You can't message this person."
80
+ author:
81
+ not_a_messager: "must be a messager (acts_as_messager)"
71
82
  sender:
72
83
  blank: "is required"
73
84
  not_a_participant: "is not a participant of this conversation"