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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +166 -0
- data/README.md +163 -28
- data/app/assets/stylesheets/chats.css +44 -0
- data/app/controllers/chats/conversations_controller.rb +43 -43
- data/app/controllers/chats/messages_controller.rb +34 -0
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +94 -0
- data/app/javascript/chats/refresh_inbox_controller.js +86 -0
- data/app/views/chats/conversations/_group.html.erb +38 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
- data/app/views/chats/conversations/index.html.erb +39 -9
- data/app/views/chats/conversations/show.html.erb +44 -3
- data/app/views/chats/messages/_composer.html.erb +4 -0
- data/app/views/chats/messages/_message.html.erb +29 -8
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +11 -0
- data/config/locales/es.yml +11 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/gemfiles/rails_7.1.gemfile +1 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/lib/chats/configuration.rb +63 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +303 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/macros.rb +24 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +81 -2
- data/lib/chats/models/conversation.rb +59 -5
- data/lib/chats/models/message.rb +67 -3
- data/lib/chats/models/participant.rb +59 -0
- data/lib/chats/models/reaction.rb +5 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +114 -15
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
- data/lib/generators/chats/templates/initializer.rb +95 -14
- data/lib/generators/chats/upgrade_generator.rb +48 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<% @
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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"
|
data/config/locales/en.yml
CHANGED
|
@@ -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"
|