chats 0.1.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 +7 -0
- data/.rubocop.yml +43 -0
- data/.simplecov +52 -0
- data/AGENTS.md +5 -0
- data/Appraisals +17 -0
- data/CHANGELOG.md +74 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/chats.css +818 -0
- data/app/controllers/chats/application_controller.rb +65 -0
- data/app/controllers/chats/conversations_controller.rb +198 -0
- data/app/controllers/chats/messages_controller.rb +118 -0
- data/app/controllers/chats/reactions_controller.rb +33 -0
- data/app/helpers/chats/engine_helper.rb +212 -0
- data/app/javascript/chats/composer_controller.js +258 -0
- data/app/javascript/chats/debounced_submit_controller.js +40 -0
- data/app/javascript/chats/thread_controller.js +855 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
- data/app/views/chats/conversations/_messages_page.html.erb +16 -0
- data/app/views/chats/conversations/_read_state.html.erb +11 -0
- data/app/views/chats/conversations/index.html.erb +54 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
- data/app/views/chats/conversations/show.html.erb +137 -0
- data/app/views/chats/messages/_composer.html.erb +67 -0
- data/app/views/chats/messages/_message.html.erb +158 -0
- data/app/views/chats/messages/create.turbo_stream.erb +6 -0
- data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
- data/app/views/chats/shared/_unread_badge.html.erb +6 -0
- data/config/importmap.rb +16 -0
- data/config/locales/en.yml +87 -0
- data/config/locales/es.yml +87 -0
- data/config/routes.rb +24 -0
- data/docs/PRD.md +254 -0
- data/docs/campfire_review.md +46 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/chats/broadcasts.rb +147 -0
- data/lib/chats/configuration.rb +286 -0
- data/lib/chats/engine.rb +146 -0
- data/lib/chats/errors.rb +20 -0
- data/lib/chats/macros.rb +28 -0
- data/lib/chats/models/application_record.rb +11 -0
- data/lib/chats/models/concerns/chat_subject.rb +35 -0
- data/lib/chats/models/concerns/messager.rb +102 -0
- data/lib/chats/models/conversation.rb +347 -0
- data/lib/chats/models/message.rb +323 -0
- data/lib/chats/models/participant.rb +151 -0
- data/lib/chats/models/reaction.rb +70 -0
- data/lib/chats/version.rb +5 -0
- data/lib/chats.rb +188 -0
- data/lib/generators/chats/install_generator.rb +62 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
- data/lib/generators/chats/templates/initializer.rb +138 -0
- data/lib/generators/chats/views_generator.rb +49 -0
- metadata +204 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# Base controller for every engine screen. It inherits from the HOST's
|
|
5
|
+
# controller (config.parent_controller, "::ApplicationController" by
|
|
6
|
+
# default) so the host's layout, helpers, auth filters, locale switching
|
|
7
|
+
# and exception handling all apply to the chat screens for free — the same
|
|
8
|
+
# integration style as api_keys' dashboard.
|
|
9
|
+
#
|
|
10
|
+
# NOTE: the superclass is resolved when this class is autoloaded, which in
|
|
11
|
+
# a booted app happens AFTER initializers — so `config.parent_controller`
|
|
12
|
+
# set in config/initializers/chats.rb is honored. In development the class
|
|
13
|
+
# is reloaded on every change, picking up config changes too.
|
|
14
|
+
class ApplicationController < Chats.config.parent_controller.constantize
|
|
15
|
+
before_action :chats_authenticate!
|
|
16
|
+
|
|
17
|
+
helper Chats::EngineHelper
|
|
18
|
+
helper_method :chats_current_messager
|
|
19
|
+
|
|
20
|
+
layout :chats_layout
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# The conversing actor for this request, via the host-configured method
|
|
25
|
+
# (`current_user` by default — Devise-compatible out of the box).
|
|
26
|
+
def chats_current_messager
|
|
27
|
+
@chats_current_messager ||= begin
|
|
28
|
+
method_name = Chats.config.current_messager_method
|
|
29
|
+
unless respond_to?(method_name, true)
|
|
30
|
+
raise Chats::ConfigurationError,
|
|
31
|
+
"chats can't find ##{method_name} on #{self.class.superclass.name}. " \
|
|
32
|
+
"Set config.current_messager_method in config/initializers/chats.rb " \
|
|
33
|
+
"to the controller method that returns the logged-in #{Chats.config.messager_class}."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
send(method_name)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def chats_authenticate!
|
|
41
|
+
method_name = Chats.config.authenticate_method
|
|
42
|
+
unless respond_to?(method_name, true)
|
|
43
|
+
raise Chats::ConfigurationError,
|
|
44
|
+
"chats can't find ##{method_name} on #{self.class.superclass.name}. " \
|
|
45
|
+
"Set config.authenticate_method in config/initializers/chats.rb " \
|
|
46
|
+
"to your authentication filter (e.g. :authenticate_user! with Devise)."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
send(method_name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# nil falls through to the parent controller's regular layout resolution,
|
|
53
|
+
# so by default chat screens look like the rest of the host app.
|
|
54
|
+
def chats_layout
|
|
55
|
+
Chats.config.layout
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Conversations are ALWAYS resolved through the viewer's own inbox
|
|
59
|
+
# relation: not-a-participant, left, or blocked-counterpart threads all
|
|
60
|
+
# come back as a plain 404 — existence is never leaked to outsiders.
|
|
61
|
+
def find_conversation(id = params[:id])
|
|
62
|
+
chats_current_messager.chats.find(id)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# The inbox (index), the thread (show), starting conversations from host
|
|
5
|
+
# pages (create), and the per-member actions (read/typing/leave/mute).
|
|
6
|
+
class ConversationsController < ApplicationController
|
|
7
|
+
before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
|
|
8
|
+
|
|
9
|
+
# The inbox. Everything is preloaded/batched so rendering N rows costs a
|
|
10
|
+
# constant number of queries (conversations + last messages + participants
|
|
11
|
+
# + one grouped unread-count query — see Conversation.unread_counts_for).
|
|
12
|
+
def index
|
|
13
|
+
@conversations = chats_current_messager.chats
|
|
14
|
+
.includes(:last_message, :subject, participants: :messager)
|
|
15
|
+
.limit(200)
|
|
16
|
+
@conversations = apply_search(@conversations)
|
|
17
|
+
@unread_counts = Chats::Conversation.unread_counts_for(chats_current_messager, @conversations)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The thread. Renders the LATEST page of messages; older pages stream in
|
|
21
|
+
# through a lazy Turbo Frame chain (keyset-paginated — see
|
|
22
|
+
# Message.before_message and _messages_page.html.erb).
|
|
23
|
+
def show
|
|
24
|
+
@participant = @conversation.participant_for(chats_current_messager)
|
|
25
|
+
|
|
26
|
+
anchor = params[:before].present? ? @conversation.messages.find_by(id: params[:before]) : nil
|
|
27
|
+
scope = @conversation.messages.includes(:sender, :reactions)
|
|
28
|
+
scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
|
|
29
|
+
scope = scope.before_message(anchor) if anchor
|
|
30
|
+
|
|
31
|
+
# Fetch newest-first + reverse so "the last N messages" render in
|
|
32
|
+
# chronological order. One extra record peeks whether older pages exist.
|
|
33
|
+
page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
|
|
34
|
+
@more_messages = page.size > Chats.config.messages_per_page
|
|
35
|
+
@messages = page.first(Chats.config.messages_per_page).reverse
|
|
36
|
+
|
|
37
|
+
if anchor
|
|
38
|
+
# Older-page request from the pagination frame: render just the page.
|
|
39
|
+
render partial: "chats/conversations/messages_page",
|
|
40
|
+
locals: { conversation: @conversation, messages: @messages, more: @more_messages }
|
|
41
|
+
else
|
|
42
|
+
# The «new messages» divider: computed BEFORE read! advances the
|
|
43
|
+
# horizon (after it, nothing is unread anymore). Anchored to the
|
|
44
|
+
# oldest unread bubble on the rendered page; when the backlog runs
|
|
45
|
+
# deeper than one page it pins to the top of the page instead —
|
|
46
|
+
# the scroll-up frame chain holds the rest.
|
|
47
|
+
if @participant&.unread?
|
|
48
|
+
@first_unread_id = @participant.unread_messages.where(id: @messages.map(&:id)).oldest_first.pick(:id) ||
|
|
49
|
+
@messages.first&.id
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Opening the thread reads it. (Live appends while the thread stays
|
|
53
|
+
# open are read via the thread controller's POST to #read.)
|
|
54
|
+
@participant&.read!
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Stale-thread catch-up: appends messages created — and replaces ones
|
|
59
|
+
# edited/tombstoned — since the newest `updated_at` the client has
|
|
60
|
+
# rendered (`?since=` in ms). The thread controller calls this when the
|
|
61
|
+
# tab wakes from a long sleep or its Turbo Stream subscription
|
|
62
|
+
# reconnects, i.e. whenever broadcasts may have been missed. Mobile
|
|
63
|
+
# WebViews suspend WebSockets aggressively, so without this a
|
|
64
|
+
# backgrounded chat silently loses messages until a manual reload.
|
|
65
|
+
# Pattern from Basecamp's Campfire (Rooms::RefreshesController):
|
|
66
|
+
# https://github.com/basecamp/once-campfire
|
|
67
|
+
def refresh
|
|
68
|
+
head :no_content and return if params[:since].blank?
|
|
69
|
+
|
|
70
|
+
since = Time.zone.at(0, params[:since].to_i, :millisecond)
|
|
71
|
+
scope = @conversation.messages.includes(:sender, :reactions)
|
|
72
|
+
scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
|
|
73
|
+
|
|
74
|
+
@new_messages = scope.created_since(since).oldest_first.limit(Chats.config.messages_per_page + 1).to_a
|
|
75
|
+
|
|
76
|
+
# A backlog deeper than one page would mean splicing an arbitrary
|
|
77
|
+
# amount of history through surgical appends; a Turbo 8 page refresh
|
|
78
|
+
# (morph + scroll preservation) re-renders the latest page + frame
|
|
79
|
+
# chain correctly instead. Raw tag rather than `turbo_stream.refresh`
|
|
80
|
+
# so we don't depend on turbo-rails ≥ 2.0 helpers.
|
|
81
|
+
if @new_messages.size > Chats.config.messages_per_page
|
|
82
|
+
render html: '<turbo-stream action="refresh"></turbo-stream>'.html_safe,
|
|
83
|
+
content_type: "text/vnd.turbo-stream.html"
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@updated_messages = scope.updated_since(since)
|
|
88
|
+
render "chats/conversations/refresh", formats: :turbo_stream
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Start (or resume) a direct conversation from a host page. The
|
|
92
|
+
# recipient/subject arrive as SIGNED GlobalIDs minted by the
|
|
93
|
+
# `chat_button_to` helper — unforgeable and purpose-scoped, so raw
|
|
94
|
+
# polymorphic params never reach `GlobalID::Locator`. Policy and block
|
|
95
|
+
# checks still run inside `chat_with` (defense in depth).
|
|
96
|
+
def create
|
|
97
|
+
recipient = locate_signed!(params.require(:recipient_sgid), purpose: :chats_recipient)
|
|
98
|
+
raise ActiveRecord::RecordNotFound unless Chats.messager_class?(recipient.class)
|
|
99
|
+
|
|
100
|
+
subject = params[:subject_sgid].presence &&
|
|
101
|
+
locate_signed!(params[:subject_sgid], purpose: :chats_subject)
|
|
102
|
+
|
|
103
|
+
conversation = chats_current_messager.chat_with(recipient, about: subject)
|
|
104
|
+
redirect_to conversation_path(conversation)
|
|
105
|
+
rescue Chats::BlockedError
|
|
106
|
+
# Fallback is the INBOX (engine root) — inside the engine, `root_path`
|
|
107
|
+
# already resolves there, never to the host root.
|
|
108
|
+
redirect_back fallback_location: conversations_path, alert: t("chats.flashes.blocked")
|
|
109
|
+
rescue Chats::NotAllowedError
|
|
110
|
+
redirect_back fallback_location: conversations_path, alert: t("chats.flashes.not_allowed")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Advance the viewer's read horizon. Called by the thread Stimulus
|
|
114
|
+
# controller (debounced) when new messages arrive while the thread is
|
|
115
|
+
# visible. Side effects (read-state broadcast, badge refresh) live in
|
|
116
|
+
# Participant#read!.
|
|
117
|
+
def read
|
|
118
|
+
@conversation.participant_for(chats_current_messager)&.read!
|
|
119
|
+
head :no_content
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Ephemeral typing ping (client throttles to ~1 every 3s while typing).
|
|
123
|
+
# Nothing is persisted; see Chats::Broadcasts.typing.
|
|
124
|
+
def typing
|
|
125
|
+
Chats::Broadcasts.typing(@conversation, chats_current_messager) if Chats.config.typing_indicators
|
|
126
|
+
head :no_content
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def leave
|
|
130
|
+
# Direct threads can't be left (mute or block instead) — leaving would
|
|
131
|
+
# strand a 1:1 thread in a weird half-state.
|
|
132
|
+
raise ActiveRecord::RecordNotFound if @conversation.direct?
|
|
133
|
+
|
|
134
|
+
title = @conversation.title_for(chats_current_messager)
|
|
135
|
+
@conversation.participant_for(chats_current_messager)&.leave!
|
|
136
|
+
redirect_to conversations_path, notice: t("chats.flashes.left", title: title)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def mute
|
|
140
|
+
@conversation.participant_for(chats_current_messager)&.mute!
|
|
141
|
+
redirect_to conversation_path(@conversation), notice: t("chats.flashes.muted")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def unmute
|
|
145
|
+
@conversation.participant_for(chats_current_messager)&.unmute!
|
|
146
|
+
redirect_to conversation_path(@conversation), notice: t("chats.flashes.unmuted")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def set_conversation
|
|
152
|
+
@conversation = find_conversation
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def locate_signed!(sgid, purpose:)
|
|
156
|
+
GlobalID::Locator.locate_signed(sgid, for: purpose) || raise(ActiveRecord::RecordNotFound)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Partial, case-insensitive matching across the inbox metadata users can
|
|
160
|
+
# actually see: participant names, conversation titles, subject labels,
|
|
161
|
+
# and message bodies. The inbox is capped at 200 rows, so metadata is
|
|
162
|
+
# filtered portably in Ruby from the already-preloaded objects while the
|
|
163
|
+
# potentially larger message-body set stays in SQL. No PostgreSQL-only
|
|
164
|
+
# full-text dependency is needed for this scale.
|
|
165
|
+
def apply_search(conversations)
|
|
166
|
+
return conversations unless Chats.config.search
|
|
167
|
+
|
|
168
|
+
query = params[:q].to_s.strip
|
|
169
|
+
return conversations if query.empty?
|
|
170
|
+
|
|
171
|
+
loaded = conversations.to_a
|
|
172
|
+
normalized_query = query.downcase
|
|
173
|
+
pattern = "%#{Chats::Conversation.sanitize_sql_like(query.downcase)}%"
|
|
174
|
+
message_match_ids =
|
|
175
|
+
if Chats.config.encrypt_messages
|
|
176
|
+
[]
|
|
177
|
+
else
|
|
178
|
+
Chats::Message.where(conversation_id: loaded.map(&:id), deleted_at: nil)
|
|
179
|
+
.where("LOWER(chats_messages.body) LIKE ?", pattern)
|
|
180
|
+
.distinct
|
|
181
|
+
.pluck(:conversation_id)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
loaded.select do |conversation|
|
|
185
|
+
message_match_ids.include?(conversation.id) ||
|
|
186
|
+
searchable_metadata(conversation).downcase.include?(normalized_query)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def searchable_metadata(conversation)
|
|
191
|
+
participant_names = conversation.participants.filter_map do |participant|
|
|
192
|
+
Chats.display_name_for(participant.messager) if participant.active?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
[conversation.title, conversation.subject_label, *participant_names].compact.join(" ")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# Sending, editing, soft-deleting, and re-rendering message bubbles.
|
|
5
|
+
class MessagesController < ApplicationController
|
|
6
|
+
before_action :set_conversation
|
|
7
|
+
before_action :set_message, only: %i[show update destroy]
|
|
8
|
+
before_action :require_ownership!, only: %i[update destroy]
|
|
9
|
+
|
|
10
|
+
# Per-sender send throttle via Rails 8's built-in controller rate
|
|
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
|
|
23
|
+
|
|
24
|
+
# A single bubble, re-rendered. Exists for one delightful reason: it's
|
|
25
|
+
# the "cancel edit" target — replacing the inline edit form back with the
|
|
26
|
+
# plain bubble via one turbo_stream GET, no full page reload.
|
|
27
|
+
def show
|
|
28
|
+
render_bubble_replacement
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create
|
|
32
|
+
@message = @conversation.messages.new(message_params.merge(sender: chats_current_messager))
|
|
33
|
+
|
|
34
|
+
if @message.save
|
|
35
|
+
respond_to do |format|
|
|
36
|
+
# The sender's OWN bubble appends instantly from this response (no
|
|
37
|
+
# waiting for the Action Cable round-trip); the broadcast then
|
|
38
|
+
# delivers the same element to everyone else — and to the sender
|
|
39
|
+
# again, where Turbo's append dedup (same DOM id) makes it a no-op.
|
|
40
|
+
format.turbo_stream
|
|
41
|
+
format.html { redirect_to conversation_path(@conversation) }
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
respond_to do |format|
|
|
45
|
+
format.turbo_stream { render :errors, status: :unprocessable_entity }
|
|
46
|
+
format.html do
|
|
47
|
+
redirect_to conversation_path(@conversation),
|
|
48
|
+
alert: @message.errors.full_messages.to_sentence
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Swap the bubble for an inline edit form (turbo_stream), with a plain
|
|
55
|
+
# page as the no-JS fallback.
|
|
56
|
+
# Edits arrive from the COMPOSER (the long-press → Editar flow re-targets
|
|
57
|
+
# the composer form at this URL with _method=patch), so failures render
|
|
58
|
+
# into the composer's error slot — same surface as failed sends.
|
|
59
|
+
def update
|
|
60
|
+
@message.edit!(message_params[:body])
|
|
61
|
+
render_bubble_replacement
|
|
62
|
+
rescue ActiveRecord::RecordInvalid, Chats::NotAllowedError
|
|
63
|
+
render :errors, status: :unprocessable_entity
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Soft delete by default: tombstone the bubble (see Message#soft_delete!).
|
|
67
|
+
def destroy
|
|
68
|
+
@message.soft_delete!
|
|
69
|
+
|
|
70
|
+
respond_to do |format|
|
|
71
|
+
format.turbo_stream do
|
|
72
|
+
if @message.destroyed?
|
|
73
|
+
render turbo_stream: turbo_stream.remove(@message)
|
|
74
|
+
else
|
|
75
|
+
render_bubble_replacement
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
format.html { redirect_to conversation_path(@conversation) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def set_conversation
|
|
85
|
+
@conversation = find_conversation(params[:conversation_id])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def set_message
|
|
89
|
+
@message = @conversation.messages.find(params[:id])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Editing/deleting is for the author alone. 404 (not 403) so the action's
|
|
93
|
+
# existence mirrors what the actor can see — consistent with every other
|
|
94
|
+
# authorization miss in the engine.
|
|
95
|
+
def require_ownership!
|
|
96
|
+
raise ActiveRecord::RecordNotFound unless @message.sent_by?(chats_current_messager)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def message_params
|
|
100
|
+
permitted = %i[body reply_to_id]
|
|
101
|
+
permitted << { files: [] } if Chats.config.attachments
|
|
102
|
+
params.require(:message).permit(*permitted)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def render_bubble_replacement
|
|
106
|
+
respond_to do |format|
|
|
107
|
+
format.turbo_stream do
|
|
108
|
+
render turbo_stream: turbo_stream.replace(
|
|
109
|
+
@message,
|
|
110
|
+
partial: "chats/messages/message",
|
|
111
|
+
locals: { message: @message }
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
format.html { redirect_to conversation_path(@conversation) }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# Emoji reactions: tap-to-toggle, one endpoint.
|
|
5
|
+
class ReactionsController < ApplicationController
|
|
6
|
+
# Toggle: adds or removes (Reaction.toggle! is race-safe through the
|
|
7
|
+
# unique index). The response re-renders the bubble for the actor; the
|
|
8
|
+
# broadcast updates everyone else.
|
|
9
|
+
def create
|
|
10
|
+
conversation = find_conversation(params[:conversation_id])
|
|
11
|
+
message = conversation.messages.find(params[:message_id])
|
|
12
|
+
|
|
13
|
+
Chats::Reaction.toggle!(
|
|
14
|
+
message: message,
|
|
15
|
+
reactor: chats_current_messager,
|
|
16
|
+
emoji: params.require(:emoji)
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
respond_to do |format|
|
|
20
|
+
format.turbo_stream do
|
|
21
|
+
render turbo_stream: turbo_stream.replace(
|
|
22
|
+
message,
|
|
23
|
+
partial: "chats/messages/message",
|
|
24
|
+
locals: { message: message }
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
format.html { redirect_to conversation_path(conversation) }
|
|
28
|
+
end
|
|
29
|
+
rescue ActiveRecord::RecordInvalid
|
|
30
|
+
head :unprocessable_entity
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# View helpers, available BOTH inside the engine's own views and in the
|
|
5
|
+
# HOST app's views (mixed into ActionView via the engine's on_load hook,
|
|
6
|
+
# the same pattern the moderate gem uses for `report_link`).
|
|
7
|
+
module EngineHelper
|
|
8
|
+
# The "message this person" affordance for host pages — a listing, a
|
|
9
|
+
# profile, an order. Renders nothing when there's no viewer, the viewer
|
|
10
|
+
# IS the target, or policy/blocks forbid the pair, so it's always safe
|
|
11
|
+
# to drop into a page unconditionally:
|
|
12
|
+
#
|
|
13
|
+
# <%= chat_button_to @driver, about: @ride, label: "Chat with driver" %>
|
|
14
|
+
#
|
|
15
|
+
# The recipient/subject travel as SIGNED GlobalIDs (purpose-scoped,
|
|
16
|
+
# minted here, verified in Chats::ConversationsController#create), so the
|
|
17
|
+
# endpoint never trusts raw polymorphic params. `expires_in: nil` because
|
|
18
|
+
# these buttons sit on long-lived pages — the default 1-month sgid expiry
|
|
19
|
+
# would quietly break stale tabs.
|
|
20
|
+
def chat_button_to(other, about: nil, label: nil, **html_options)
|
|
21
|
+
viewer = chats_viewer
|
|
22
|
+
return if viewer.nil? || other.nil? || viewer == other
|
|
23
|
+
return unless Chats.can_message?(viewer, other)
|
|
24
|
+
|
|
25
|
+
params = { recipient_sgid: other.to_sgid(expires_in: nil, for: :chats_recipient).to_s }
|
|
26
|
+
params[:subject_sgid] = about.to_sgid(expires_in: nil, for: :chats_subject).to_s if about
|
|
27
|
+
|
|
28
|
+
button_to(
|
|
29
|
+
label || I18n.t("chats.buttons.chat"),
|
|
30
|
+
chats_routes.conversations_path,
|
|
31
|
+
params: params,
|
|
32
|
+
method: :post,
|
|
33
|
+
**html_options
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A live unread-conversations badge, embeddable on ANY page (nav bars,
|
|
38
|
+
# tab docks). Subscribes to the messager's badge stream so it updates in
|
|
39
|
+
# real time without refreshing the page it sits on (see
|
|
40
|
+
# Chats::Broadcasts for why badges get their own stream).
|
|
41
|
+
def chats_unread_badge(messager = chats_viewer)
|
|
42
|
+
return if messager.nil?
|
|
43
|
+
|
|
44
|
+
safe_join([
|
|
45
|
+
turbo_stream_from(messager, :chats_badge),
|
|
46
|
+
render(partial: "chats/shared/unread_badge", locals: { count: messager.unread_chats_count })
|
|
47
|
+
])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# An avatar for any messager: whatever `config.messager_avatar` returns
|
|
51
|
+
# (URL / ActiveStorage attachment / variant) or an initials placeholder.
|
|
52
|
+
def chats_messager_avatar(messager, css_class: "chats-avatar", loading: "eager")
|
|
53
|
+
name = Chats.display_name_for(messager).presence || "?"
|
|
54
|
+
source = begin
|
|
55
|
+
avatar = Chats.avatar_for(messager)
|
|
56
|
+
# An ActiveStorage attachment that isn't attached renders as a broken
|
|
57
|
+
# image — treat it as "no avatar" instead.
|
|
58
|
+
avatar.respond_to?(:attached?) && !avatar.attached? ? nil : avatar
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if source
|
|
62
|
+
image_tag chats_avatar_image_source(source), alt: name, class: css_class, loading: loading
|
|
63
|
+
else
|
|
64
|
+
initials = name.split.first(2).map { |word| word[0] }.join.upcase
|
|
65
|
+
tag.span(initials, class: "#{css_class} chats-avatar--initials", "aria-hidden": true)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# WhatsApp-style compact timestamps, deliberately numeric so they need no
|
|
70
|
+
# date-name translations (many apps don't bundle rails-i18n; the gem must
|
|
71
|
+
# not require it): today → "14:05", this week-ish → "9/6", older → "9/6/25".
|
|
72
|
+
def chats_timestamp(time)
|
|
73
|
+
return "" if time.nil?
|
|
74
|
+
|
|
75
|
+
local = time.in_time_zone
|
|
76
|
+
if local.today?
|
|
77
|
+
local.strftime("%H:%M")
|
|
78
|
+
elsif local.year == Time.current.year
|
|
79
|
+
local.strftime("%-d/%-m")
|
|
80
|
+
else
|
|
81
|
+
local.strftime("%-d/%-m/%y")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The inbox-row preview line for a conversation's latest message.
|
|
86
|
+
def chats_preview_for(conversation, viewer)
|
|
87
|
+
message = conversation.last_message
|
|
88
|
+
return I18n.t("chats.inbox.no_messages") if message.nil?
|
|
89
|
+
|
|
90
|
+
text =
|
|
91
|
+
if message.deleted?
|
|
92
|
+
I18n.t("chats.message.deleted")
|
|
93
|
+
elsif message.body.present?
|
|
94
|
+
message.body.truncate(90)
|
|
95
|
+
elsif message.attachments?
|
|
96
|
+
"📷 #{I18n.t("chats.message.attachment")}"
|
|
97
|
+
else
|
|
98
|
+
""
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Tombstones read as a statement ("Message deleted") — never prefixed.
|
|
102
|
+
# Otherwise: "You:" for own messages, and in GROUPS the sender's first
|
|
103
|
+
# name (WhatsApp-style), since "who said it" is ambiguous there. System
|
|
104
|
+
# messages (no sender) stay bare.
|
|
105
|
+
prefix =
|
|
106
|
+
if message.deleted?
|
|
107
|
+
nil
|
|
108
|
+
elsif message.sent_by?(viewer)
|
|
109
|
+
I18n.t("chats.inbox.you_prefix")
|
|
110
|
+
elsif conversation.group? && message.sender
|
|
111
|
+
"#{Chats.display_name_for(message.sender).split.first}:"
|
|
112
|
+
end
|
|
113
|
+
[prefix, text].compact.join(" ")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The avatar shown on an inbox row: the counterpart's (direct) or an
|
|
117
|
+
# initials disc from the group name.
|
|
118
|
+
def chats_conversation_avatar(conversation, viewer)
|
|
119
|
+
if conversation.direct?
|
|
120
|
+
other = conversation.other_participants(viewer).first
|
|
121
|
+
chats_messager_avatar(other&.messager)
|
|
122
|
+
else
|
|
123
|
+
initials = conversation.title_for(viewer).split.first(2).map { |word| word[0] }.join.upcase
|
|
124
|
+
tag.span(initials.presence || "👥", class: "chats-avatar chats-avatar--initials chats-avatar--group",
|
|
125
|
+
"aria-hidden": true)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The gem's bundled stylesheet (CSS-variable themed — see chats.css).
|
|
130
|
+
# Called from the engine's own views; hosts that eject + restyle the
|
|
131
|
+
# views with their own framework simply don't include it.
|
|
132
|
+
def chats_styles
|
|
133
|
+
stylesheet_link_tag "chats", "data-turbo-track": "reload"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Engine URL helpers that work from EVERY render context — this is more
|
|
137
|
+
# subtle than it looks, and the reason the broadcast partials use it:
|
|
138
|
+
#
|
|
139
|
+
# * host views & the broadcast renderer (Turbo broadcasts render through
|
|
140
|
+
# the host's ApplicationController renderer — no engine request, no
|
|
141
|
+
# SCRIPT_NAME): the mounted proxy (`chats.`) carries the mount prefix
|
|
142
|
+
# baked in at mount time, so URLs come out right with no request.
|
|
143
|
+
# * engine views during requests: the engine controller inherits from
|
|
144
|
+
# the host's ApplicationController, so the proxy is available there
|
|
145
|
+
# too (and bare helpers would also work — the proxy just works
|
|
146
|
+
# everywhere).
|
|
147
|
+
# * no mount at all (bare view tests): fall back to the engine's own
|
|
148
|
+
# url_helpers (prefix-less, but nothing better exists without a mount).
|
|
149
|
+
#
|
|
150
|
+
# NOTE: assumes the default mount name (`mount Chats::Engine => "/x"`
|
|
151
|
+
# auto-names the proxy `chats`). Hosts using `as: :something_else` should
|
|
152
|
+
# override this helper.
|
|
153
|
+
def chats_routes
|
|
154
|
+
respond_to?(:chats) ? chats : Chats::Engine.routes.url_helpers
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# The current messager in whatever context the helper runs (host or
|
|
160
|
+
# engine view), resolved through the configured controller method.
|
|
161
|
+
def chats_viewer
|
|
162
|
+
method_name = Chats.config.current_messager_method
|
|
163
|
+
respond_to?(method_name) ? send(method_name) : nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Active Storage routes are drawn on the host app, not on this isolated
|
|
167
|
+
# engine. A bare `image_tag variant` is fine in normal host views, but
|
|
168
|
+
# inside engine views it asks the engine route set to polymorphically
|
|
169
|
+
# resolve ActiveStorage::VariantWithRecord and can fall through to
|
|
170
|
+
# `to_model`. Build the same proxy/redirect routes Rails would build,
|
|
171
|
+
# explicitly against the host route set, before `image_tag` sees it.
|
|
172
|
+
def chats_avatar_image_source(source)
|
|
173
|
+
return source unless chats_active_storage_source?(source)
|
|
174
|
+
|
|
175
|
+
routes = chats_main_routes
|
|
176
|
+
|
|
177
|
+
if source.respond_to?(:variation) && source.respond_to?(:blob)
|
|
178
|
+
routes.rails_representation_url(source, only_path: true)
|
|
179
|
+
elsif source.respond_to?(:blob)
|
|
180
|
+
routes.rails_blob_url(source.blob, only_path: true)
|
|
181
|
+
elsif source.respond_to?(:signed_id) && source.respond_to?(:filename)
|
|
182
|
+
routes.rails_blob_url(source, only_path: true)
|
|
183
|
+
else
|
|
184
|
+
source
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def chats_active_storage_source?(source)
|
|
189
|
+
defined?(ActiveStorage) && source.class.name.start_with?("ActiveStorage::")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def chats_main_routes
|
|
193
|
+
respond_to?(:main_app) ? main_app : Rails.application.routes.url_helpers
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Expose the helpers to the HOST app's views (isolated engines don't share
|
|
199
|
+
# helpers automatically). The hook lives HERE, at the bottom of the file that
|
|
200
|
+
# defines the constant — not in an engine initializer — so it's
|
|
201
|
+
# self-resolving: whenever this file loads (eager load, autoload on first
|
|
202
|
+
# use, or the engine's to_prepare touch), the constant already exists by the
|
|
203
|
+
# time the hook can possibly run. Registering it from an initializer instead
|
|
204
|
+
# would blow up at boot in hosts where ActionView is already loaded during
|
|
205
|
+
# initializers (web-console does this) because `include Chats::EngineHelper`
|
|
206
|
+
# would fire before the autoloader is ready. Same pattern as the moderate
|
|
207
|
+
# gem's report_link helper.
|
|
208
|
+
if defined?(ActiveSupport)
|
|
209
|
+
ActiveSupport.on_load(:action_view) do
|
|
210
|
+
include Chats::EngineHelper
|
|
211
|
+
end
|
|
212
|
+
end
|