chats 0.1.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1037 -0
- data/README.md +621 -40
- data/app/assets/stylesheets/chats.css +906 -2
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +396 -86
- data/app/controllers/chats/messages_controller.rb +35 -13
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +230 -7
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1120 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +76 -0
- data/app/javascript/chats/split_controller.js +131 -0
- data/app/javascript/chats/thread_controller.js +322 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +61 -0
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
- data/app/views/chats/conversations/_thread.html.erb +147 -0
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +142 -15
- data/app/views/chats/conversations/pane.html.erb +14 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +18 -134
- data/app/views/chats/conversations/window.html.erb +26 -0
- data/app/views/chats/dock/_badge.html.erb +12 -0
- data/app/views/chats/dock/_dock.html.erb +139 -0
- data/app/views/chats/dock/_signal.html.erb +26 -0
- data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
- data/app/views/chats/dock/_window.html.erb +23 -0
- data/app/views/chats/dock/_window_header.html.erb +75 -0
- data/app/views/chats/dock/_window_shell.html.erb +17 -0
- data/app/views/chats/messages/_action.html.erb +15 -0
- data/app/views/chats/messages/_composer.html.erb +84 -56
- data/app/views/chats/messages/_message.html.erb +43 -15
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +10 -1
- data/config/locales/en.yml +40 -0
- data/config/locales/es.yml +40 -0
- data/config/routes.rb +8 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/docs/screenshots/composer-actions.png +0 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/docs/screenshots/inbox.png +0 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/docs/screenshots/system-note-action.png +0 -0
- data/docs/screenshots/thread-actions-phone.png +0 -0
- data/gemfiles/rails_7.1.gemfile +3 -0
- data/gemfiles/rails_7.2.gemfile +3 -0
- data/gemfiles/rails_8.1.gemfile +3 -0
- data/lib/chats/action.rb +85 -0
- data/lib/chats/broadcasts.rb +178 -17
- data/lib/chats/configuration.rb +115 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +363 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/inbox_refresh_job.rb +14 -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 +122 -6
- data/lib/chats/models/conversation.rb +153 -19
- data/lib/chats/models/message.rb +292 -21
- data/lib/chats/models/participant.rb +67 -0
- data/lib/chats/models/reaction.rb +18 -0
- data/lib/chats/send_rate_limited.rb +30 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +123 -15
- data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
- data/lib/generators/chats/templates/initializer.rb +125 -14
- data/lib/generators/chats/upgrade_generator.rb +79 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +51 -2
|
@@ -6,20 +6,13 @@ 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
|
-
|
|
11
|
-
# limiting (https://api.rubyonrails.org/classes/ActionController/RateLimiting.html).
|
|
12
|
-
# Feature-detected so the gem still loads on Rails 7.1 (where this is
|
|
13
|
-
# simply not enforced). Keyed by messager, not IP — one abusive account
|
|
14
|
-
# behind a corporate NAT must not silence the rest.
|
|
15
|
-
if respond_to?(:rate_limit) && Chats.config.send_rate_limit
|
|
16
|
-
rate_limit(
|
|
17
|
-
**Chats.config.send_rate_limit,
|
|
18
|
-
only: :create,
|
|
19
|
-
by: -> { send(Chats.config.current_messager_method)&.to_gid&.to_s || request.remote_ip },
|
|
20
|
-
with: -> { head :too_many_requests }
|
|
21
|
-
)
|
|
22
|
-
end
|
|
15
|
+
include Chats::SendRateLimited
|
|
23
16
|
|
|
24
17
|
# A single bubble, re-rendered. Exists for one delightful reason: it's
|
|
25
18
|
# the "cancel edit" target — replacing the inline edit form back with the
|
|
@@ -40,6 +33,17 @@ module Chats
|
|
|
40
33
|
format.turbo_stream
|
|
41
34
|
format.html { redirect_to conversation_path(@conversation) }
|
|
42
35
|
end
|
|
36
|
+
elsif locked?
|
|
37
|
+
# The subject closed the conversation (Chats::ChatSubject#
|
|
38
|
+
# chat_locked?) — possibly while this composer sat open. Swap the
|
|
39
|
+
# composer for the locked notice instead of flashing an error at
|
|
40
|
+
# someone whose screen is now lying to them. 422, never a raise.
|
|
41
|
+
respond_to do |format|
|
|
42
|
+
format.turbo_stream { render :locked, status: :unprocessable_entity }
|
|
43
|
+
format.html do
|
|
44
|
+
redirect_to conversation_path(@conversation), alert: @conversation.locked_notice
|
|
45
|
+
end
|
|
46
|
+
end
|
|
43
47
|
else
|
|
44
48
|
respond_to do |format|
|
|
45
49
|
format.turbo_stream { render :errors, status: :unprocessable_entity }
|
|
@@ -85,6 +89,24 @@ module Chats
|
|
|
85
89
|
@conversation = find_conversation(params[:conversation_id])
|
|
86
90
|
end
|
|
87
91
|
|
|
92
|
+
# Did THIS save fail because the conversation is locked? Reads the error
|
|
93
|
+
# type, not the conversation, so a message that also failed validation
|
|
94
|
+
# for another reason still reports that reason.
|
|
95
|
+
def locked?
|
|
96
|
+
@message.errors.of_kind?(:base, :locked)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Gate the action, explain it in place: the composer becomes the locked
|
|
100
|
+
# notice (422), or a plain redirect carrying the notice without Turbo.
|
|
101
|
+
def refuse_when_locked!
|
|
102
|
+
return unless @conversation.locked?
|
|
103
|
+
|
|
104
|
+
respond_to do |format|
|
|
105
|
+
format.turbo_stream { render :locked, status: :unprocessable_entity }
|
|
106
|
+
format.html { redirect_to conversation_path(@conversation), alert: @conversation.locked_notice }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
88
110
|
def set_message
|
|
89
111
|
@message = @conversation.messages.find(params[:id])
|
|
90
112
|
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,19 @@ 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
|
+
composer_actions
|
|
16
|
+
locked_composer
|
|
17
|
+
message_meta
|
|
18
|
+
dock_panel_footer
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
8
21
|
# The "message this person" affordance for host pages — a listing, a
|
|
9
22
|
# profile, an order. Renders nothing when there's no viewer, the viewer
|
|
10
23
|
# IS the target, or policy/blocks forbid the pair, so it's always safe
|
|
@@ -12,18 +25,24 @@ module Chats
|
|
|
12
25
|
#
|
|
13
26
|
# <%= chat_button_to @driver, about: @ride, label: "Chat with driver" %>
|
|
14
27
|
#
|
|
28
|
+
# `dock: true` opens the conversation in a floating window of the
|
|
29
|
+
# messaging dock (`chats_dock`) instead of navigating to it. It belongs
|
|
30
|
+
# with `chats_dock` on the same page: without the dock the button's Turbo
|
|
31
|
+
# Stream has nowhere to land and nothing happens.
|
|
32
|
+
#
|
|
15
33
|
# The recipient/subject travel as SIGNED GlobalIDs (purpose-scoped,
|
|
16
34
|
# minted here, verified in Chats::ConversationsController#create), so the
|
|
17
35
|
# endpoint never trusts raw polymorphic params. `expires_in: nil` because
|
|
18
36
|
# these buttons sit on long-lived pages — the default 1-month sgid expiry
|
|
19
37
|
# would quietly break stale tabs.
|
|
20
|
-
def chat_button_to(other, about: nil, label: nil, **html_options)
|
|
38
|
+
def chat_button_to(other, about: nil, label: nil, dock: false, **html_options)
|
|
21
39
|
viewer = chats_viewer
|
|
22
40
|
return if viewer.nil? || other.nil? || viewer == other
|
|
23
41
|
return unless Chats.can_message?(viewer, other)
|
|
24
42
|
|
|
25
43
|
params = { recipient_sgid: other.to_sgid(expires_in: nil, for: :chats_recipient).to_s }
|
|
26
44
|
params[:subject_sgid] = about.to_sgid(expires_in: nil, for: :chats_subject).to_s if about
|
|
45
|
+
params[:dock] = 1 if dock
|
|
27
46
|
|
|
28
47
|
button_to(
|
|
29
48
|
label || I18n.t("chats.buttons.chat"),
|
|
@@ -38,13 +57,97 @@ module Chats
|
|
|
38
57
|
# tab docks). Subscribes to the messager's badge stream so it updates in
|
|
39
58
|
# real time without refreshing the page it sits on (see
|
|
40
59
|
# Chats::Broadcasts for why badges get their own stream).
|
|
60
|
+
#
|
|
61
|
+
# The viewer's own badge also catches up on updates its stream missed
|
|
62
|
+
# (the chats--unread-badge controller asks GET /badge on a reconnect, on
|
|
63
|
+
# a return to a hidden page, on a restored page). Its wrapper is
|
|
64
|
+
# `display: contents` in chats.css, so it changes no layout.
|
|
41
65
|
def chats_unread_badge(messager = chats_viewer)
|
|
42
66
|
return if messager.nil?
|
|
43
67
|
|
|
44
|
-
safe_join([
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
68
|
+
badge = safe_join([
|
|
69
|
+
turbo_stream_from(messager, :chats_badge),
|
|
70
|
+
render(partial: "chats/shared/unread_badge", locals: { count: messager.unread_chats_count })
|
|
71
|
+
])
|
|
72
|
+
return badge unless messager == chats_viewer
|
|
73
|
+
|
|
74
|
+
tag.span(badge, class: "chats-unread-badge-live",
|
|
75
|
+
data: { controller: "chats--unread-badge",
|
|
76
|
+
"chats--unread-badge-url-value": chats_routes.badge_conversations_path })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The messaging dock, LinkedIn / Steam style: a bar in the bottom-right
|
|
80
|
+
# corner with the unread badge, a panel of recent conversations, and
|
|
81
|
+
# floating conversation windows. One line in the host's layout:
|
|
82
|
+
#
|
|
83
|
+
# <%= chats_dock %>
|
|
84
|
+
#
|
|
85
|
+
# Renders nothing for a signed-out visitor, and nothing on the engine's
|
|
86
|
+
# own pages (the inbox, a thread): those already ARE the messaging UI,
|
|
87
|
+
# and a window there would duplicate the thread's DOM ids the live
|
|
88
|
+
# streams address. The dock is permanent across Turbo visits, so the
|
|
89
|
+
# server can't take it away on the next page: it also hides ITSELF on
|
|
90
|
+
# any page carrying `data-chats-dock-hide` (the engine's pages do; put
|
|
91
|
+
# it on your own messaging pages). `everywhere: true` renders it on the
|
|
92
|
+
# engine's pages and ignores data-chats-dock-hide (it still never opens
|
|
93
|
+
# a window for a conversation the page itself shows).
|
|
94
|
+
#
|
|
95
|
+
# windows: how many windows may be open at once (3, and never more
|
|
96
|
+
# than fit beside the panel: 2 at 1280px, 1 at 768px)
|
|
97
|
+
# recent: how many conversations the panel lists (10)
|
|
98
|
+
# launcher: false drops the phone's floating button, for a host that
|
|
99
|
+
# opens the sheet from its own nav (see chats_dock_toggle)
|
|
100
|
+
def chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false)
|
|
101
|
+
viewer = chats_viewer
|
|
102
|
+
return if viewer.nil?
|
|
103
|
+
return if chats_engine_page? && !everywhere
|
|
104
|
+
|
|
105
|
+
render(partial: "chats/dock/dock",
|
|
106
|
+
locals: { viewer: viewer, windows: windows, recent: recent, launcher: launcher, everywhere: everywhere })
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The data that makes ANY link to a conversation — a recent-list row, a
|
|
110
|
+
# host's own link — open that conversation's dock window instead of
|
|
111
|
+
# navigating, when a dock is on the page. The link stays a real link: a
|
|
112
|
+
# modified click, a page without the dock, or no JavaScript navigates.
|
|
113
|
+
#
|
|
114
|
+
# <%= link_to "Chat", chats.conversation_path(c), data: chats_dock_opener(c) %>
|
|
115
|
+
def chats_dock_opener(conversation)
|
|
116
|
+
{ chats_dock_conversation_id: conversation.id }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The data that makes a host's own Messages link open the dock's panel
|
|
120
|
+
# (the full-screen sheet on a phone) instead of navigating, when a dock
|
|
121
|
+
# is on the page. Pair it with `chats_dock(launcher: false)` to replace
|
|
122
|
+
# the phone's floating button with your nav's. Without the dock, or
|
|
123
|
+
# without JavaScript, the link goes where its href says.
|
|
124
|
+
#
|
|
125
|
+
# <%= link_to "Messages", chats.conversations_path, data: chats_dock_toggle %>
|
|
126
|
+
def chats_dock_toggle
|
|
127
|
+
{ chats_dock_toggle: true }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The CLDR plural categories JavaScript's Intl.PluralRules answers with.
|
|
131
|
+
PLURAL_CATEGORIES = %i[zero one two few many other].freeze
|
|
132
|
+
|
|
133
|
+
# Every plural form of +key+ the current locale provides, for client-side
|
|
134
|
+
# pluralization ({ one: "…", few: "…", other: "…" }). The gem ships
|
|
135
|
+
# one/other; a host adds the forms its languages need (Polish few/many,
|
|
136
|
+
# Arabic zero/two…) in its own locale files. Empty when the key is
|
|
137
|
+
# missing, never a "translation missing" string.
|
|
138
|
+
def chats_plural_forms(key)
|
|
139
|
+
forms = I18n.t(key, default: {})
|
|
140
|
+
forms.is_a?(Hash) ? forms.slice(*PLURAL_CATEGORIES) : {}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Whether +viewer+ muted +conversation+, read from the preloaded seats
|
|
144
|
+
# (the inbox loads them), so a row costs no query for it.
|
|
145
|
+
def chats_muted?(conversation, viewer)
|
|
146
|
+
return false if viewer.nil?
|
|
147
|
+
|
|
148
|
+
conversation.participants.any? do |seat|
|
|
149
|
+
seat.muted? && seat.messager_type == viewer.class.polymorphic_name && seat.messager_id == viewer.id
|
|
150
|
+
end
|
|
48
151
|
end
|
|
49
152
|
|
|
50
153
|
# An avatar for any messager: whatever `config.messager_avatar` returns
|
|
@@ -117,8 +220,7 @@ module Chats
|
|
|
117
220
|
# initials disc from the group name.
|
|
118
221
|
def chats_conversation_avatar(conversation, viewer)
|
|
119
222
|
if conversation.direct?
|
|
120
|
-
|
|
121
|
-
chats_messager_avatar(other&.messager)
|
|
223
|
+
chats_messager_avatar(conversation.counterpart_for(viewer))
|
|
122
224
|
else
|
|
123
225
|
initials = conversation.title_for(viewer).split.first(2).map { |word| word[0] }.join.upcase
|
|
124
226
|
tag.span(initials.presence || "👥", class: "chats-avatar chats-avatar--initials chats-avatar--group",
|
|
@@ -126,6 +228,122 @@ module Chats
|
|
|
126
228
|
end
|
|
127
229
|
end
|
|
128
230
|
|
|
231
|
+
# --- Slots ----------------------------------------------------------------
|
|
232
|
+
#
|
|
233
|
+
# Named extension points the bundled views render WHEN a partial exists
|
|
234
|
+
# at `chats/slots/_<name>`. Hosts (and engines mounted on top of chats,
|
|
235
|
+
# like support_desk) drop a file in and it appears; nobody has to eject
|
|
236
|
+
# a whole screen to add one row or one button. Absent slots cost one
|
|
237
|
+
# memoized template lookup and render nothing.
|
|
238
|
+
#
|
|
239
|
+
# app/views/chats/slots/_inbox_top.html.erb
|
|
240
|
+
#
|
|
241
|
+
# The slots: inbox_top, inbox_empty, conversation_header_actions,
|
|
242
|
+
# composer_actions, locked_composer, message_meta, dock_panel_footer.
|
|
243
|
+
def chats_slot(name, **locals)
|
|
244
|
+
return unless chats_slot?(name)
|
|
245
|
+
|
|
246
|
+
render(partial: "chats/slots/#{name}", locals: locals)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Whether a slot partial exists. Memoized per view instance, so a slot
|
|
250
|
+
# rendered inside a collection costs ONE lookup per request, not one per
|
|
251
|
+
# row. Anything outside SLOTS is ignored rather than looked up: the slot
|
|
252
|
+
# names are a contract, and a typo should render nothing instead of
|
|
253
|
+
# quietly becoming a new extension point nobody documented.
|
|
254
|
+
def chats_slot?(name)
|
|
255
|
+
key = name.to_s
|
|
256
|
+
return false unless Chats::EngineHelper::SLOTS.include?(key)
|
|
257
|
+
|
|
258
|
+
@chats_slots ||= {}
|
|
259
|
+
return @chats_slots[key] if @chats_slots.key?(key)
|
|
260
|
+
|
|
261
|
+
@chats_slots[key] = lookup_context.exists?("chats/slots/#{key}", [], true)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# --- Messager display -----------------------------------------------------
|
|
265
|
+
|
|
266
|
+
# Whether block/report affordances apply to this messager. False for
|
|
267
|
+
# `acts_as_messager blockable: false` (a support desk, a bot) — the
|
|
268
|
+
# bundled views hide the affordance instead of asking hosts to branch on
|
|
269
|
+
# class.
|
|
270
|
+
def chats_blockable?(messager)
|
|
271
|
+
Chats.blockable?(messager)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# A messager's profile URL per `config.messager_url`, or nil.
|
|
275
|
+
def chats_messager_url(messager)
|
|
276
|
+
Chats.messager_url_for(messager)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# A messager's name, linked to their profile when `config.messager_url`
|
|
280
|
+
# gives one and plain text when it doesn't — so the gem never renders a
|
|
281
|
+
# dead anchor or assumes a `user_path` exists.
|
|
282
|
+
def chats_messager_name(messager, css_class: nil)
|
|
283
|
+
name = Chats.display_name_for(messager)
|
|
284
|
+
url = chats_messager_url(messager)
|
|
285
|
+
|
|
286
|
+
url.present? ? link_to(name, url, class: css_class) : tag.span(name, class: css_class)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The "official account" mark for a messager declared `acts_as_messager
|
|
290
|
+
# verified: true`, and NIL for everyone else — so any view, bundled or
|
|
291
|
+
# host, can drop it next to a name unconditionally:
|
|
292
|
+
#
|
|
293
|
+
# <%= chats_messager_name(author) %><%= chats_verified_badge(author) %>
|
|
294
|
+
#
|
|
295
|
+
# `config.verified_badge` swaps the glyph for the host's own design
|
|
296
|
+
# system; the default renders `chats/shared/_verified_badge`, whose
|
|
297
|
+
# colour is the `--chats-verified` CSS variable.
|
|
298
|
+
def chats_verified_badge(messager)
|
|
299
|
+
return unless Chats.verified?(messager)
|
|
300
|
+
|
|
301
|
+
custom = Chats.config.verified_badge
|
|
302
|
+
return custom.call(messager) if custom
|
|
303
|
+
|
|
304
|
+
render(partial: "chats/shared/verified_badge", locals: { messager: messager })
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The signature line under a signed message ("— Lucía G."), or nil.
|
|
308
|
+
def chats_message_signature(message)
|
|
309
|
+
Chats.message_signature_for(message)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# --- Grouped inbox rows ---------------------------------------------------
|
|
313
|
+
|
|
314
|
+
# Where a stacked inbox row goes: straight to the thread when the stack
|
|
315
|
+
# holds exactly one conversation, otherwise to the stack itself.
|
|
316
|
+
def chats_group_path(group)
|
|
317
|
+
return chats_routes.conversation_path(group.conversation) if group.single?
|
|
318
|
+
|
|
319
|
+
chats_group_path_for(group.messager)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# The stack list for a messager: the host's `group_path:` callable when
|
|
323
|
+
# `acts_as_messager` declared one (support_desk points it at its own
|
|
324
|
+
# screen), else chats' own filtered inbox.
|
|
325
|
+
def chats_group_path_for(messager)
|
|
326
|
+
custom = messager.class.try(:chat_group_path)
|
|
327
|
+
path = custom&.call(chats_viewer)
|
|
328
|
+
|
|
329
|
+
path.presence || chats_routes.conversations_path(with: Chats.inbox_with_sgid(messager))
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The messaging page's title: the inbox's, or +conversation+'s.
|
|
333
|
+
# `config.page_title` when the host sets one, else the localized
|
|
334
|
+
# `chats.page_title.*`. ONE source for both ways a conversation opens:
|
|
335
|
+
# the bundled pages hand it to `content_for :title` on a full visit, and
|
|
336
|
+
# the two-pane page's pane carries it (`data-title`) for chats--split to
|
|
337
|
+
# give the tab when a row opens it, since Turbo leaves <head> alone then.
|
|
338
|
+
def chats_page_title(conversation = nil)
|
|
339
|
+
viewer = chats_current_messager
|
|
340
|
+
custom = Chats.config.page_title&.call(conversation, viewer)
|
|
341
|
+
return custom.to_s if custom.present?
|
|
342
|
+
return t("chats.page_title.inbox") unless conversation
|
|
343
|
+
|
|
344
|
+
t("chats.page_title.conversation", name: conversation.title_for(viewer))
|
|
345
|
+
end
|
|
346
|
+
|
|
129
347
|
# The gem's bundled stylesheet (CSS-variable themed — see chats.css).
|
|
130
348
|
# Called from the engine's own views; hosts that eject + restyle the
|
|
131
349
|
# views with their own framework simply don't include it.
|
|
@@ -163,6 +381,11 @@ module Chats
|
|
|
163
381
|
respond_to?(method_name) ? send(method_name) : nil
|
|
164
382
|
end
|
|
165
383
|
|
|
384
|
+
# Whether this view is one of the engine's own screens.
|
|
385
|
+
def chats_engine_page?
|
|
386
|
+
respond_to?(:controller) && controller.is_a?(Chats::ApplicationController)
|
|
387
|
+
end
|
|
388
|
+
|
|
166
389
|
# Active Storage routes are drawn on the host app, not on this isolated
|
|
167
390
|
# engine. A bare `image_tag variant` is fine in normal host views, but
|
|
168
391
|
# inside engine views it asks the engine route set to polymorphically
|
|
@@ -76,8 +76,11 @@ export default class extends Controller {
|
|
|
76
76
|
// are disabled while editing (edits are body-only by design).
|
|
77
77
|
|
|
78
78
|
beginEdit(event) {
|
|
79
|
-
const { id, body } = event.detail || {}
|
|
79
|
+
const { id, body, thread } = event.detail || {}
|
|
80
80
|
if (!id || !this.hasInputTarget) return
|
|
81
|
+
// Several threads can share a page (the dock's windows): only the
|
|
82
|
+
// composer of the thread the message lives in edits it.
|
|
83
|
+
if (thread && !thread.contains(this.element)) return
|
|
81
84
|
|
|
82
85
|
this.createAction ||= this.element.action
|
|
83
86
|
this.element.action = `${this.createAction}/${id}`
|
|
@@ -112,6 +115,16 @@ export default class extends Controller {
|
|
|
112
115
|
this.inputTarget.setSelectionRange(this.inputTarget.value.length, this.inputTarget.value.length)
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
// Escape while editing cancels the edit, and only that: the dock (if the
|
|
119
|
+
// composer is in one of its windows) sees the event handled and leaves
|
|
120
|
+
// the window alone.
|
|
121
|
+
escape(event) {
|
|
122
|
+
if (!this.editing) return
|
|
123
|
+
|
|
124
|
+
event.preventDefault()
|
|
125
|
+
this.cancelEdit()
|
|
126
|
+
}
|
|
127
|
+
|
|
115
128
|
cancelEdit(event) {
|
|
116
129
|
event?.preventDefault()
|
|
117
130
|
this.exitEdit()
|