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,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_view/record_identifier"
|
|
4
|
+
|
|
5
|
+
module Chats
|
|
6
|
+
# ALL of the gem's real-time fan-out, in one file — read this and you know
|
|
7
|
+
# the entire live behavior. Three streams exist:
|
|
8
|
+
#
|
|
9
|
+
# 1. The CONVERSATION stream (`turbo_stream_from @conversation` on the
|
|
10
|
+
# thread page): surgical message append/replace/remove, the read-state
|
|
11
|
+
# payload, and the typing custom action. One render per event, shared
|
|
12
|
+
# by every subscriber — bubbles are rendered VIEWER-AGNOSTIC and the
|
|
13
|
+
# `chats--thread` Stimulus controller aligns own-vs-other client-side
|
|
14
|
+
# (comparing each bubble's sender key against its own `me` value),
|
|
15
|
+
# which is what makes a single broadcast render possible at all.
|
|
16
|
+
# 2. The per-messager INBOX stream (`[messager, :chats_inbox]`, subscribed
|
|
17
|
+
# only on the inbox page): we broadcast Turbo 8 page REFRESHES instead
|
|
18
|
+
# of surgically patching rows. An inbox row is intensely per-viewer
|
|
19
|
+
# (unread badge, bold state, ordering), so patching it from a shared
|
|
20
|
+
# broadcast would force per-viewer renders of per-viewer partials;
|
|
21
|
+
# a refresh makes each client re-request the page and get a correct,
|
|
22
|
+
# personalized render with morphing + scroll preservation for free.
|
|
23
|
+
# Turbo debounces refreshes per stream and tags them with the
|
|
24
|
+
# originating request id so the tab that caused the change skips its
|
|
25
|
+
# own refresh. https://turbo.hotwired.dev/handbook/streams
|
|
26
|
+
#
|
|
27
|
+
# 3. The per-messager BADGE stream (`[messager, :chats_badge]`,
|
|
28
|
+
# subscribable from ANY page via the `chats_unread_badge` helper —
|
|
29
|
+
# e.g. a bottom-nav dock): a tiny `update` of the badge element.
|
|
30
|
+
# Deliberately separate from the inbox stream — if badges rode on it,
|
|
31
|
+
# every page embedding a badge would full-refresh on every message.
|
|
32
|
+
#
|
|
33
|
+
# Everything uses the `_later` variants (rendering happens in background
|
|
34
|
+
# jobs, never inline in the request) except `message_destroyed`, where the
|
|
35
|
+
# record is gone and can't be serialized for a job.
|
|
36
|
+
module Broadcasts
|
|
37
|
+
extend ActionView::RecordIdentifier # dom_id
|
|
38
|
+
|
|
39
|
+
MESSAGE_PARTIAL = "chats/messages/message"
|
|
40
|
+
BADGE_PARTIAL = "chats/shared/unread_badge"
|
|
41
|
+
READ_STATE_PARTIAL = "chats/conversations/read_state"
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def message_created(message)
|
|
45
|
+
conversation = message.conversation
|
|
46
|
+
|
|
47
|
+
Turbo::StreamsChannel.broadcast_append_later_to(
|
|
48
|
+
conversation,
|
|
49
|
+
target: dom_id(conversation, :messages),
|
|
50
|
+
partial: MESSAGE_PARTIAL,
|
|
51
|
+
locals: { message: message }
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
fan_out_inboxes(conversation, skip_badge_for: message.sender)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def message_updated(message)
|
|
58
|
+
Turbo::StreamsChannel.broadcast_replace_later_to(
|
|
59
|
+
message.conversation,
|
|
60
|
+
target: dom_id(message),
|
|
61
|
+
partial: MESSAGE_PARTIAL,
|
|
62
|
+
locals: { message: message }
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Edits/deletes change the inbox preview when they touch the latest
|
|
66
|
+
# message; a refresh is debounced and cheap, so just fan out.
|
|
67
|
+
fan_out_inboxes(message.conversation, badges: false)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def message_destroyed(message)
|
|
71
|
+
# Hard delete: the row is gone — broadcast synchronously (nothing to
|
|
72
|
+
# serialize into a job) and let inboxes re-render.
|
|
73
|
+
Turbo::StreamsChannel.broadcast_remove_to(
|
|
74
|
+
message.conversation,
|
|
75
|
+
target: dom_id(message)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
fan_out_inboxes(message.conversation, badges: false)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The viewer-agnostic read-state payload (a hidden element carrying
|
|
82
|
+
# every participant's read horizon as JSON). The thread controller
|
|
83
|
+
# turns it into a per-viewer "Seen" indicator client-side.
|
|
84
|
+
def read_state(conversation)
|
|
85
|
+
Turbo::StreamsChannel.broadcast_replace_later_to(
|
|
86
|
+
conversation,
|
|
87
|
+
target: dom_id(conversation, :read_state),
|
|
88
|
+
partial: READ_STATE_PARTIAL,
|
|
89
|
+
locals: { conversation: conversation }
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Ephemeral "X is typing…" — a Turbo Stream CUSTOM ACTION (registered
|
|
94
|
+
# in chats/thread_controller.js as Turbo.StreamActions.chats_typing),
|
|
95
|
+
# carrying the typist in attributes. Nothing is persisted; if nobody's
|
|
96
|
+
# subscribed it evaporates, which is exactly right for presence-ish
|
|
97
|
+
# signals. No `_later`: typing is only meaningful NOW.
|
|
98
|
+
def typing(conversation, messager)
|
|
99
|
+
Turbo::StreamsChannel.broadcast_action_to(
|
|
100
|
+
conversation,
|
|
101
|
+
action: :chats_typing,
|
|
102
|
+
target: dom_id(conversation, :typing),
|
|
103
|
+
attributes: {
|
|
104
|
+
"data-name" => Chats.display_name_for(messager),
|
|
105
|
+
"data-key" => Chats.messager_key(messager)
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def refresh_inbox_of(messager)
|
|
111
|
+
Turbo::StreamsChannel.broadcast_refresh_later_to(messager, :chats_inbox)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def update_badge_of(messager)
|
|
115
|
+
# REPLACE, not update: the partial renders the whole badge element
|
|
116
|
+
# (its id included) — an `update` would nest it inside the existing
|
|
117
|
+
# element and duplicate the id. Replace keeps it idempotent.
|
|
118
|
+
Turbo::StreamsChannel.broadcast_replace_later_to(
|
|
119
|
+
messager, :chats_badge,
|
|
120
|
+
target: "chats_unread_badge",
|
|
121
|
+
partial: BADGE_PARTIAL,
|
|
122
|
+
# Counted at broadcast-enqueue time: the badge is advisory UI; a
|
|
123
|
+
# count that's seconds stale self-heals on the next event.
|
|
124
|
+
# reorder(nil): COUNT(DISTINCT) + the inbox's COALESCE order would
|
|
125
|
+
# error on PostgreSQL.
|
|
126
|
+
locals: { count: Chats::Conversation.inbox_for(messager).unread_by(messager).reorder(nil).distinct.count }
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# New activity: every active participant's inbox refreshes (including
|
|
133
|
+
# the sender's — their OTHER tabs/devices need the new ordering; the
|
|
134
|
+
# originating tab skips its own refresh via Turbo's request-id dedup),
|
|
135
|
+
# and every participant except the sender gets a fresh unread badge.
|
|
136
|
+
def fan_out_inboxes(conversation, skip_badge_for: nil, badges: true)
|
|
137
|
+
conversation.participants.active.includes(:messager).each do |participant|
|
|
138
|
+
messager = participant.messager
|
|
139
|
+
next if messager.nil?
|
|
140
|
+
|
|
141
|
+
refresh_inbox_of(messager)
|
|
142
|
+
update_badge_of(messager) if badges && messager != skip_badge_for
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# All of the gem's knobs, with delightful defaults: a fresh `Configuration`
|
|
5
|
+
# is fully working out of the box for the classic Devise + `User` Rails app.
|
|
6
|
+
#
|
|
7
|
+
# Two design rules, shared across the gem ecosystem (moderate, api_keys, …):
|
|
8
|
+
#
|
|
9
|
+
# 1. Class names are stored as STRINGS and constantized lazily, so the
|
|
10
|
+
# initializer can reference app classes before they're loaded and
|
|
11
|
+
# everything survives Zeitwerk reloads.
|
|
12
|
+
# 2. Cross-gem seams are PROCS with no-op defaults, so `chats` runs
|
|
13
|
+
# standalone and lights up when the host wires moderate / Noticed /
|
|
14
|
+
# goodmail in.
|
|
15
|
+
class Configuration
|
|
16
|
+
ATTACHMENT_MODES = [false, :images, :any].freeze
|
|
17
|
+
DELETION_MODES = [false, :soft, :hard].freeze
|
|
18
|
+
|
|
19
|
+
# --- Host integration -----------------------------------------------------
|
|
20
|
+
|
|
21
|
+
# The primary conversing model. Participants stay polymorphic regardless
|
|
22
|
+
# (any `acts_as_messager` model can join a conversation); this is the
|
|
23
|
+
# class the engine's controllers resolve `current_messager` against and
|
|
24
|
+
# the default for docs/generators.
|
|
25
|
+
attr_reader :messager_class
|
|
26
|
+
|
|
27
|
+
# The controller the engine inherits from. Pointing this at the host's
|
|
28
|
+
# `ApplicationController` (the default) gives the engine the host's
|
|
29
|
+
# layout, helpers, `current_user`, locale switching, etc. — the same
|
|
30
|
+
# pattern api_keys uses for its dashboard.
|
|
31
|
+
attr_reader :parent_controller
|
|
32
|
+
|
|
33
|
+
# Method called on the controller to fetch the current messager
|
|
34
|
+
# (`:current_user` works with Devise out of the box).
|
|
35
|
+
attr_accessor :current_messager_method
|
|
36
|
+
|
|
37
|
+
# Method called as a `before_action` to require authentication
|
|
38
|
+
# (`:authenticate_user!` works with Devise out of the box).
|
|
39
|
+
attr_accessor :authenticate_method
|
|
40
|
+
|
|
41
|
+
# Optional explicit layout for the engine's screens. `nil` (default)
|
|
42
|
+
# inherits whatever layout the parent controller resolves — usually the
|
|
43
|
+
# host's `application` layout. Set to e.g. `"app"` if your host renders
|
|
44
|
+
# its logged-in surfaces with a different layout.
|
|
45
|
+
attr_accessor :layout
|
|
46
|
+
|
|
47
|
+
# --- Feature flags --------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
# Group conversations (3+ participants, a title, join/leave). Direct 1:1
|
|
50
|
+
# conversations are always available.
|
|
51
|
+
attr_accessor :groups
|
|
52
|
+
|
|
53
|
+
# Emoji reactions on messages.
|
|
54
|
+
attr_accessor :reactions
|
|
55
|
+
|
|
56
|
+
# Read receipts ("Seen") + unread tracking. Read state is stored on the
|
|
57
|
+
# participant (`last_read_at`), not per-message — see Chats::Participant
|
|
58
|
+
# for the rationale.
|
|
59
|
+
attr_accessor :read_receipts
|
|
60
|
+
|
|
61
|
+
# Live "X is typing…" indicators (Turbo Stream custom action; no Action
|
|
62
|
+
# Cable channel of its own, see Chats::ConversationsController#typing).
|
|
63
|
+
attr_accessor :typing_indicators
|
|
64
|
+
|
|
65
|
+
# Whether senders can edit their own messages after sending.
|
|
66
|
+
attr_accessor :editing
|
|
67
|
+
|
|
68
|
+
# What "delete" means: `:soft` keeps a tombstone ("message deleted", body
|
|
69
|
+
# gone — the WhatsApp model, and the safest for Trust & Safety evidence),
|
|
70
|
+
# `:hard` destroys the row, `false` disables deletion entirely.
|
|
71
|
+
attr_reader :deletion
|
|
72
|
+
|
|
73
|
+
# Message attachments: `false` (none), `:images` (images only — the
|
|
74
|
+
# default), or `:any` (any content type). Backed by ActiveStorage's
|
|
75
|
+
# `has_many_attached`, so the host must have ActiveStorage installed to
|
|
76
|
+
# enable this.
|
|
77
|
+
attr_reader :attachments
|
|
78
|
+
|
|
79
|
+
# Inbox search box (partial matching across participant names, conversation
|
|
80
|
+
# titles, subject labels, and message bodies — no extra dependencies; swap
|
|
81
|
+
# in pg_search & friends by overriding the controller if you outgrow it).
|
|
82
|
+
attr_accessor :search
|
|
83
|
+
|
|
84
|
+
# --- Limits ---------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
attr_accessor :messages_per_page, :max_message_length, :max_group_size, :max_attachment_size,
|
|
87
|
+
:max_attachments_per_message
|
|
88
|
+
|
|
89
|
+
# Per-sender send throttle, enforced with Rails 8's built-in controller
|
|
90
|
+
# `rate_limit` when available (feature-detected; on Rails 7.1 it's a
|
|
91
|
+
# no-op). Shape: `{ to: Integer, within: ActiveSupport::Duration }`.
|
|
92
|
+
# Set to `nil` to disable.
|
|
93
|
+
attr_reader :send_rate_limit
|
|
94
|
+
|
|
95
|
+
# Encrypt message bodies at rest with ActiveRecord Encryption
|
|
96
|
+
# (`encrypts :body`). Requires the host to have AR encryption keys
|
|
97
|
+
# configured (`bin/rails db:encryption:init`). Note: turning this on
|
|
98
|
+
# makes message-body search degrade to title/participant matching.
|
|
99
|
+
attr_accessor :encrypt_messages
|
|
100
|
+
|
|
101
|
+
# --- Policies (procs) -----------------------------------------------------
|
|
102
|
+
|
|
103
|
+
# Host authorization on top of block enforcement: may +sender+ open a
|
|
104
|
+
# conversation with / send to +recipient+? Blocking is ALWAYS enforced
|
|
105
|
+
# underneath this (see Chats.can_message?) so a permissive or buggy
|
|
106
|
+
# policy can never let a blocked pair talk.
|
|
107
|
+
attr_reader :can_message
|
|
108
|
+
|
|
109
|
+
# May +creator+ create a group conversation?
|
|
110
|
+
attr_reader :can_create_group
|
|
111
|
+
|
|
112
|
+
# --- Ecosystem seams (procs, no-op defaults) ------------------------------
|
|
113
|
+
|
|
114
|
+
# ->(messager) { ids } — every messager id that can't talk with the given
|
|
115
|
+
# one (bidirectional). Wire to the moderate gem with one line:
|
|
116
|
+
# config.blocked_messager_ids = ->(user) { Moderate.blocked_ids_for(user) }
|
|
117
|
+
attr_reader :blocked_messager_ids
|
|
118
|
+
|
|
119
|
+
# ->(event, **payload) — domain-moment fan-out (see Chats.notify).
|
|
120
|
+
attr_reader :notifier
|
|
121
|
+
|
|
122
|
+
# --- Display procs (used by the bundled views) ----------------------------
|
|
123
|
+
|
|
124
|
+
# ->(messager) { String } — how a messager is named in inboxes, bubbles
|
|
125
|
+
# and typing indicators. The default tries the obvious candidates.
|
|
126
|
+
attr_reader :messager_display_name
|
|
127
|
+
|
|
128
|
+
# ->(messager) { url/attachment/nil } — an avatar for the messager.
|
|
129
|
+
# Return anything `image_tag` accepts (a URL, an ActiveStorage attachment
|
|
130
|
+
# or variant), or nil to render an initials placeholder.
|
|
131
|
+
attr_reader :messager_avatar
|
|
132
|
+
|
|
133
|
+
def initialize
|
|
134
|
+
@messager_class = "User"
|
|
135
|
+
@parent_controller = "::ApplicationController"
|
|
136
|
+
@current_messager_method = :current_user
|
|
137
|
+
@authenticate_method = :authenticate_user!
|
|
138
|
+
@layout = nil
|
|
139
|
+
|
|
140
|
+
@groups = true
|
|
141
|
+
@reactions = true
|
|
142
|
+
@read_receipts = true
|
|
143
|
+
@typing_indicators = true
|
|
144
|
+
@editing = true
|
|
145
|
+
@deletion = :soft
|
|
146
|
+
@attachments = :images
|
|
147
|
+
@search = true
|
|
148
|
+
|
|
149
|
+
@messages_per_page = 30
|
|
150
|
+
@max_message_length = 5_000
|
|
151
|
+
@max_group_size = 32
|
|
152
|
+
@max_attachment_size = 10 * 1024 * 1024 # 10 MB
|
|
153
|
+
@max_attachments_per_message = 4
|
|
154
|
+
@send_rate_limit = { to: 60, within: 60 } # 60 messages per minute per sender
|
|
155
|
+
|
|
156
|
+
@encrypt_messages = false
|
|
157
|
+
|
|
158
|
+
@can_message = ->(_sender, _recipient) { true }
|
|
159
|
+
@can_create_group = ->(_creator) { true }
|
|
160
|
+
|
|
161
|
+
@blocked_messager_ids = ->(_messager) { [] }
|
|
162
|
+
@notifier = ->(_event, **_payload) {}
|
|
163
|
+
|
|
164
|
+
@messager_display_name = lambda do |messager|
|
|
165
|
+
messager.try(:display_name) || messager.try(:name) ||
|
|
166
|
+
messager.try(:full_name) || messager.try(:username) ||
|
|
167
|
+
messager.try(:email) || "#{messager.class.model_name.human} #{messager.id}"
|
|
168
|
+
end
|
|
169
|
+
@messager_avatar = ->(messager) { messager.try(:avatar) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# --- Validating setters ---------------------------------------------------
|
|
173
|
+
#
|
|
174
|
+
# Fail at boot with a plain-English message, not at 3am with a NoMethodError.
|
|
175
|
+
|
|
176
|
+
def messager_class=(value)
|
|
177
|
+
name = value.is_a?(Class) ? value.name : value.to_s
|
|
178
|
+
raise ConfigurationError, "messager_class can't be blank" if name.strip.empty?
|
|
179
|
+
|
|
180
|
+
@messager_class = name
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def parent_controller=(value)
|
|
184
|
+
name = value.is_a?(Class) ? value.name : value.to_s
|
|
185
|
+
raise ConfigurationError, "parent_controller can't be blank" if name.strip.empty?
|
|
186
|
+
|
|
187
|
+
@parent_controller = name
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def attachments=(value)
|
|
191
|
+
normalized = normalize_flag(value)
|
|
192
|
+
unless ATTACHMENT_MODES.include?(normalized)
|
|
193
|
+
raise ConfigurationError, "attachments must be one of #{ATTACHMENT_MODES.inspect}, got #{value.inspect}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
@attachments = normalized
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def deletion=(value)
|
|
200
|
+
normalized = normalize_flag(value)
|
|
201
|
+
unless DELETION_MODES.include?(normalized)
|
|
202
|
+
raise ConfigurationError, "deletion must be one of #{DELETION_MODES.inspect}, got #{value.inspect}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
@deletion = normalized
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def send_rate_limit=(value)
|
|
209
|
+
if value.nil?
|
|
210
|
+
@send_rate_limit = nil
|
|
211
|
+
return
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
hash = value.to_h.symbolize_keys
|
|
215
|
+
unless hash[:to].is_a?(Integer) && hash[:to].positive? && hash[:within].respond_to?(:to_i)
|
|
216
|
+
raise ConfigurationError,
|
|
217
|
+
"send_rate_limit must be nil or { to: Integer, within: duration }, got #{value.inspect}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
@send_rate_limit = hash
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def can_message=(value)
|
|
224
|
+
@can_message = ensure_callable(value, "can_message")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def can_create_group=(value)
|
|
228
|
+
@can_create_group = ensure_callable(value, "can_create_group")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def blocked_messager_ids=(value)
|
|
232
|
+
@blocked_messager_ids = ensure_callable(value, "blocked_messager_ids")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def notifier=(value)
|
|
236
|
+
@notifier = ensure_callable(value, "notifier")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def messager_display_name=(value)
|
|
240
|
+
@messager_display_name = ensure_callable(value, "messager_display_name")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def messager_avatar=(value)
|
|
244
|
+
@messager_avatar = ensure_callable(value, "messager_avatar")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Cross-field validation, run at the end of `Chats.configure`.
|
|
248
|
+
def validate!
|
|
249
|
+
if max_message_length && max_message_length < 1
|
|
250
|
+
raise ConfigurationError, "max_message_length must be positive (got #{max_message_length})"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if max_group_size && max_group_size < 3
|
|
254
|
+
# 2 participants is a direct conversation; a "group" of 2 is a smell.
|
|
255
|
+
raise ConfigurationError, "max_group_size must be at least 3 (got #{max_group_size})"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if messages_per_page.to_i < 1
|
|
259
|
+
raise ConfigurationError, "messages_per_page must be positive (got #{messages_per_page.inspect})"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
true
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# The constantized messager class (resolved lazily — see class comment).
|
|
266
|
+
def messager_model
|
|
267
|
+
messager_class.constantize
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
private
|
|
271
|
+
|
|
272
|
+
def normalize_flag(value)
|
|
273
|
+
return value if value == false || value.nil?
|
|
274
|
+
|
|
275
|
+
value.to_sym
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def ensure_callable(value, name)
|
|
279
|
+
unless value.respond_to?(:call)
|
|
280
|
+
raise ConfigurationError, "#{name} must respond to #call (a proc/lambda), got #{value.inspect}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
value
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
data/lib/chats/engine.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
require "turbo-rails"
|
|
5
|
+
|
|
6
|
+
module Chats
|
|
7
|
+
# The mountable engine: wires autoloading, migrations, locales, the
|
|
8
|
+
# ActiveRecord macros, importmap pins, asset paths, and boot-time model
|
|
9
|
+
# configuration into the host app.
|
|
10
|
+
class Engine < ::Rails::Engine
|
|
11
|
+
isolate_namespace Chats
|
|
12
|
+
|
|
13
|
+
# -------------------------------------------------------------------------
|
|
14
|
+
# Zeitwerk: the gem keeps its ActiveRecord models under lib/chats/models
|
|
15
|
+
# (same layout as the moderate gem) so the whole domain ships in lib/ and
|
|
16
|
+
# the engine's app/ tree only holds the web layer (controllers, helpers,
|
|
17
|
+
# views). For that to autoload correctly we manage the loader by hand:
|
|
18
|
+
#
|
|
19
|
+
# - `push_dir(lib/chats, namespace: Chats)` makes lib/chats/models/...
|
|
20
|
+
# autoloadable *under the Chats namespace*.
|
|
21
|
+
# - `collapse(models)` + `collapse(models/concerns)` mean the files in
|
|
22
|
+
# those folders define Chats::Conversation / Chats::Messager — not
|
|
23
|
+
# Chats::Models::Conversation.
|
|
24
|
+
# - The SPINE files (version/errors/configuration/macros/engine) are
|
|
25
|
+
# required explicitly by lib/chats.rb at boot, so they must be
|
|
26
|
+
# *ignored* by the loader or Zeitwerk would complain about double
|
|
27
|
+
# definitions / unmanaged constants.
|
|
28
|
+
# -------------------------------------------------------------------------
|
|
29
|
+
LIB_ROOT = File.expand_path("..", __dir__)
|
|
30
|
+
CHATS_LIB = File.expand_path("chats", LIB_ROOT)
|
|
31
|
+
|
|
32
|
+
ZEITWERK_IGNORED = %w[
|
|
33
|
+
version.rb errors.rb configuration.rb engine.rb macros.rb
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
initializer "chats.autoload", before: :set_autoload_paths do
|
|
37
|
+
loader = Rails.autoloaders.main
|
|
38
|
+
|
|
39
|
+
ZEITWERK_IGNORED.each do |file|
|
|
40
|
+
path = File.join(CHATS_LIB, file)
|
|
41
|
+
loader.ignore(path) if File.exist?(path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
%w[models models/concerns].each do |dir|
|
|
45
|
+
path = File.join(CHATS_LIB, dir)
|
|
46
|
+
loader.collapse(path) if File.directory?(path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
loader.push_dir(CHATS_LIB, namespace: Chats)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
config.eager_load_paths << CHATS_LIB
|
|
53
|
+
|
|
54
|
+
# Make the gem's migrations runnable from the host without copying
|
|
55
|
+
# (`rails db:migrate` picks them up) — the install generator still copies
|
|
56
|
+
# a host-owned migration, which is the recommended path; this initializer
|
|
57
|
+
# mainly serves the dummy app and hosts that prefer engine-owned
|
|
58
|
+
# migrations.
|
|
59
|
+
initializer "chats.migrations" do |app|
|
|
60
|
+
unless app.root.to_s == root.to_s
|
|
61
|
+
config.paths["db/migrate"].expanded.each do |path|
|
|
62
|
+
app.config.paths["db/migrate"] << path
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Expose `acts_as_messager` / `acts_as_chat_subject` on every AR model.
|
|
68
|
+
initializer "chats.active_record" do
|
|
69
|
+
ActiveSupport.on_load(:active_record) do
|
|
70
|
+
extend Chats::Macros
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Ship the gem's locale files (en, es). Host locale files with the same
|
|
75
|
+
# keys override these automatically (I18n's load order puts the app last).
|
|
76
|
+
initializer "chats.locales" do |app|
|
|
77
|
+
app.config.i18n.load_path += Dir[root.join("config", "locales", "**", "*.{rb,yml}").to_s]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# NOTE: the host-facing helpers (`chat_button_to`, `chats_unread_badge`, …)
|
|
81
|
+
# are exposed to ActionView from the BOTTOM of engine_helper.rb itself
|
|
82
|
+
# (moderate's proven pattern), NOT from an initializer here: an
|
|
83
|
+
# `on_load(:action_view)` registered during initializers fires
|
|
84
|
+
# IMMEDIATELY in hosts where something (web-console, a mailer preview…)
|
|
85
|
+
# already loaded ActionView — and at that point the autoloader can't
|
|
86
|
+
# resolve Chats::EngineHelper yet (NameError at boot). Keeping the hook
|
|
87
|
+
# in the same file as the constant makes it self-resolving; the
|
|
88
|
+
# `to_prepare` touch below guarantees the file loads on every boot and
|
|
89
|
+
# code reload even before anything references it.
|
|
90
|
+
|
|
91
|
+
# -------------------------------------------------------------------------
|
|
92
|
+
# JavaScript: the engine ships tiny Stimulus controllers (thread, composer,
|
|
93
|
+
# debounced-submit) with NO build step, pinned for importmap-rails hosts.
|
|
94
|
+
#
|
|
95
|
+
# The pin keys live under "controllers/chats/..." ON PURPOSE: the stock
|
|
96
|
+
# Rails `app/javascript/controllers/index.js` calls
|
|
97
|
+
# `eagerLoadControllersFrom("controllers", application)`, which scans the
|
|
98
|
+
# rendered importmap for keys matching ^controllers/.*_controller$ and
|
|
99
|
+
# registers each one, deriving the identifier from the path
|
|
100
|
+
# ("controllers/chats/thread_controller" → "chats--thread"; see
|
|
101
|
+
# stimulus-rails' stimulus-loading.js, registerControllerFromPath).
|
|
102
|
+
# Result: a host with the default Stimulus setup gets the chats--*
|
|
103
|
+
# controllers REGISTERED AUTOMATICALLY, zero JS changes.
|
|
104
|
+
#
|
|
105
|
+
# We `unshift` (not `<<`) our importmap so the HOST's pins are drawn after
|
|
106
|
+
# ours — importmap-rails resolves duplicate pins last-wins, so a host can
|
|
107
|
+
# override any chats controller by pinning the same key (or just dropping
|
|
108
|
+
# a file at app/javascript/controllers/chats/thread_controller.js, which
|
|
109
|
+
# `pin_all_from "app/javascript/controllers"` then pins over ours).
|
|
110
|
+
# importmap-rails appends the app's own config/importmap.rb inside its
|
|
111
|
+
# "importmap" initializer and draws everything in path order:
|
|
112
|
+
# https://github.com/rails/importmap-rails (lib/importmap/engine.rb)
|
|
113
|
+
# -------------------------------------------------------------------------
|
|
114
|
+
initializer "chats.importmap", before: "importmap" do |app|
|
|
115
|
+
if app.config.respond_to?(:importmap)
|
|
116
|
+
app.config.importmap.paths.unshift(root.join("config/importmap.rb"))
|
|
117
|
+
# Sweep the importmap cache when our JS changes (dev nicety).
|
|
118
|
+
app.config.importmap.cache_sweepers << root.join("app/javascript")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Serve the engine's JS + CSS through the host's asset pipeline
|
|
123
|
+
# (propshaft or sprockets — both honor config.assets.paths).
|
|
124
|
+
initializer "chats.assets" do |app|
|
|
125
|
+
if app.config.respond_to?(:assets)
|
|
126
|
+
app.config.assets.paths << root.join("app/javascript")
|
|
127
|
+
app.config.assets.paths << root.join("app/assets/stylesheets")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Apply boot-time configuration that has to touch autoloaded classes —
|
|
132
|
+
# runs on every reload in development so it stays applied to fresh ones.
|
|
133
|
+
config.to_prepare do
|
|
134
|
+
# Touch the helper so its bottom-of-file on_load(:action_view) hook
|
|
135
|
+
# registers even if no engine code was referenced yet (see NOTE above).
|
|
136
|
+
# (Assigned to appease Lint/Void — the constant REFERENCE is the point.)
|
|
137
|
+
_loaded = Chats::EngineHelper
|
|
138
|
+
|
|
139
|
+
if Chats.config.encrypt_messages && Chats::Message.respond_to?(:encrypts)
|
|
140
|
+
# Opt-in encryption at rest (config.encrypt_messages = true).
|
|
141
|
+
# `deterministic: false` (the default) is correct for free text.
|
|
142
|
+
Chats::Message.encrypts :body
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
data/lib/chats/errors.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# Base class for every error this gem raises, so hosts can
|
|
5
|
+
# `rescue Chats::Error` to catch anything chats-specific.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised by `Chats.configure` / setters when the configuration is invalid
|
|
9
|
+
# (unknown filter mode, blank class name, non-callable hook, …).
|
|
10
|
+
class ConfigurationError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when trying to open a conversation with (or send a message to)
|
|
13
|
+
# someone the sender is blocked with — in either direction. Controllers
|
|
14
|
+
# translate this into a friendly flash; model-level callers get the raise.
|
|
15
|
+
class BlockedError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised when the host `can_message` policy (or a feature flag like
|
|
18
|
+
# `config.groups = false`) forbids the attempted action.
|
|
19
|
+
class NotAllowedError < Error; end
|
|
20
|
+
end
|
data/lib/chats/macros.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# The two host-facing macros. The engine extends `ActiveRecord::Base` with
|
|
5
|
+
# this module (via `ActiveSupport.on_load(:active_record)`), so any model
|
|
6
|
+
# can declare:
|
|
7
|
+
#
|
|
8
|
+
# class User < ApplicationRecord
|
|
9
|
+
# acts_as_messager # can converse: inbox, DMs, groups
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# class Ride < ApplicationRecord
|
|
13
|
+
# acts_as_chat_subject # conversations can be *about* a ride
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Each macro just includes the corresponding concern — all the behavior
|
|
17
|
+
# lives in Chats::Messager / Chats::ChatSubject so it's discoverable,
|
|
18
|
+
# testable, and `include`-able directly when a host prefers that style.
|
|
19
|
+
module Macros
|
|
20
|
+
def acts_as_messager
|
|
21
|
+
include Chats::Messager
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def acts_as_chat_subject
|
|
25
|
+
include Chats::ChatSubject
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# Abstract base for every chats model. Kept separate from the host's
|
|
5
|
+
# ApplicationRecord on purpose: the gem's models must not inherit host
|
|
6
|
+
# callbacks/scopes, and the host must be able to swap its own base class
|
|
7
|
+
# without touching ours.
|
|
8
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
9
|
+
self.abstract_class = true
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# Included by `acts_as_chat_subject`. Lets conversations be *about* a host
|
|
5
|
+
# record — `user.chat_with(driver, about: ride)` — which both threads the
|
|
6
|
+
# right people into the right context and shows up as a context line in
|
|
7
|
+
# the inbox/thread UI.
|
|
8
|
+
#
|
|
9
|
+
# Override +chat_subject_label+ to control that context line:
|
|
10
|
+
#
|
|
11
|
+
# class Ride::Listing < ApplicationRecord
|
|
12
|
+
# acts_as_chat_subject
|
|
13
|
+
# def chat_subject_label = "#{origin_locality} → #{destination_locality}"
|
|
14
|
+
# end
|
|
15
|
+
module ChatSubject
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
included do
|
|
19
|
+
# `nullify`, not `destroy`: deleting the subject (a ride, an order)
|
|
20
|
+
# must never delete people's conversation history — the thread just
|
|
21
|
+
# loses its context line.
|
|
22
|
+
has_many :chat_conversations,
|
|
23
|
+
class_name: "Chats::Conversation",
|
|
24
|
+
as: :subject,
|
|
25
|
+
dependent: :nullify
|
|
26
|
+
|
|
27
|
+
Chats.register_chat_subject(self)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Short human label shown as the conversation's context line.
|
|
31
|
+
def chat_subject_label
|
|
32
|
+
"#{self.class.model_name.human} #{id}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|