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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +233 -0
- data/README.md +214 -28
- data/app/assets/stylesheets/chats.css +86 -0
- data/app/controllers/chats/conversations_controller.rb +39 -42
- 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 +113 -2
- data/app/javascript/chats/refresh_inbox_controller.js +86 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
- data/app/views/chats/conversations/_group.html.erb +41 -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 +48 -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/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +13 -0
- data/config/locales/es.yml +13 -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 +79 -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 +33 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +99 -2
- data/lib/chats/models/conversation.rb +78 -7
- 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 +122 -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 +106 -14
- data/lib/generators/chats/upgrade_generator.rb +48 -0
- 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
|
-
@
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@
|
|
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.
|
|
80
|
-
#
|
|
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
|
|
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
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|