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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +43 -0
  3. data/.simplecov +52 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +17 -0
  6. data/CHANGELOG.md +74 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +384 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/chats.css +818 -0
  12. data/app/controllers/chats/application_controller.rb +65 -0
  13. data/app/controllers/chats/conversations_controller.rb +198 -0
  14. data/app/controllers/chats/messages_controller.rb +118 -0
  15. data/app/controllers/chats/reactions_controller.rb +33 -0
  16. data/app/helpers/chats/engine_helper.rb +212 -0
  17. data/app/javascript/chats/composer_controller.js +258 -0
  18. data/app/javascript/chats/debounced_submit_controller.js +40 -0
  19. data/app/javascript/chats/thread_controller.js +855 -0
  20. data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
  21. data/app/views/chats/conversations/_messages_page.html.erb +16 -0
  22. data/app/views/chats/conversations/_read_state.html.erb +11 -0
  23. data/app/views/chats/conversations/index.html.erb +54 -0
  24. data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
  25. data/app/views/chats/conversations/show.html.erb +137 -0
  26. data/app/views/chats/messages/_composer.html.erb +67 -0
  27. data/app/views/chats/messages/_message.html.erb +158 -0
  28. data/app/views/chats/messages/create.turbo_stream.erb +6 -0
  29. data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
  30. data/app/views/chats/shared/_unread_badge.html.erb +6 -0
  31. data/config/importmap.rb +16 -0
  32. data/config/locales/en.yml +87 -0
  33. data/config/locales/es.yml +87 -0
  34. data/config/routes.rb +24 -0
  35. data/docs/PRD.md +254 -0
  36. data/docs/campfire_review.md +46 -0
  37. data/gemfiles/rails_7.1.gemfile +36 -0
  38. data/gemfiles/rails_7.2.gemfile +36 -0
  39. data/gemfiles/rails_8.1.gemfile +36 -0
  40. data/lib/chats/broadcasts.rb +147 -0
  41. data/lib/chats/configuration.rb +286 -0
  42. data/lib/chats/engine.rb +146 -0
  43. data/lib/chats/errors.rb +20 -0
  44. data/lib/chats/macros.rb +28 -0
  45. data/lib/chats/models/application_record.rb +11 -0
  46. data/lib/chats/models/concerns/chat_subject.rb +35 -0
  47. data/lib/chats/models/concerns/messager.rb +102 -0
  48. data/lib/chats/models/conversation.rb +347 -0
  49. data/lib/chats/models/message.rb +323 -0
  50. data/lib/chats/models/participant.rb +151 -0
  51. data/lib/chats/models/reaction.rb +70 -0
  52. data/lib/chats/version.rb +5 -0
  53. data/lib/chats.rb +188 -0
  54. data/lib/generators/chats/install_generator.rb +62 -0
  55. data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
  56. data/lib/generators/chats/templates/initializer.rb +138 -0
  57. data/lib/generators/chats/views_generator.rb +49 -0
  58. 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