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
data/lib/chats.rb ADDED
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "global_id"
5
+
6
+ require_relative "chats/version"
7
+ require_relative "chats/errors"
8
+ require_relative "chats/configuration"
9
+ require_relative "chats/macros"
10
+
11
+ require_relative "chats/engine" if defined?(::Rails::Engine)
12
+
13
+ # == Chats
14
+ #
15
+ # A drop-in, real-time messaging engine for Rails: DMs, group chats, reactions,
16
+ # attachments, read receipts — Hotwire-native, polymorphic, adapter-driven.
17
+ #
18
+ # The public surface is intentionally tiny:
19
+ #
20
+ # Chats.configure { |config| ... } # one block, in an initializer
21
+ # acts_as_messager # on any model that can converse
22
+ # acts_as_chat_subject # on any model conversations can be about
23
+ #
24
+ # user.chat_with(other) # find-or-create a direct conversation
25
+ # user.message!(other, "hello!") # ...and say something in one line
26
+ #
27
+ # Everything else (controllers, views, broadcasts) ships with the engine and
28
+ # is overridable the Devise way (`rails g chats:views`).
29
+ module Chats
30
+ class << self
31
+ # --- Configuration --------------------------------------------------------
32
+
33
+ def config
34
+ @config ||= Configuration.new
35
+ end
36
+
37
+ alias configuration config
38
+
39
+ def configure
40
+ yield config if block_given?
41
+ config.validate!
42
+ config
43
+ end
44
+
45
+ # Reset all global state. Used by the test suite to keep examples isolated;
46
+ # also handy in a console when experimenting with configuration.
47
+ def reset!
48
+ @config = Configuration.new
49
+ @messager_classes = nil
50
+ @subject_classes = nil
51
+ self
52
+ end
53
+
54
+ # --- Registries -----------------------------------------------------------
55
+ #
56
+ # `acts_as_messager` / `acts_as_chat_subject` self-register the calling
57
+ # class here. We store class NAMES (strings), not Class objects, so the
58
+ # registry survives Zeitwerk code reloading in development (a reloaded
59
+ # class is a brand-new object; its name is stable).
60
+
61
+ def register_messager(klass)
62
+ messager_class_names << klass.name if klass.name
63
+ end
64
+
65
+ def register_chat_subject(klass)
66
+ subject_class_names << klass.name if klass.name
67
+ end
68
+
69
+ def messager_class_names
70
+ @messager_class_names ||= Set.new
71
+ end
72
+
73
+ def subject_class_names
74
+ @subject_class_names ||= Set.new
75
+ end
76
+
77
+ # Whether +klass+ (a Class or class name) is a registered messager.
78
+ # Ancestor-aware so an STI subclass of a messager is accepted too.
79
+ def messager_class?(klass)
80
+ registered_class?(messager_class_names, klass)
81
+ end
82
+
83
+ def chat_subject_class?(klass)
84
+ registered_class?(subject_class_names, klass)
85
+ end
86
+
87
+ # --- Ecosystem seams ------------------------------------------------------
88
+
89
+ # The single source of truth for "who can't talk to whom". Wraps the
90
+ # host-provided `config.blocked_messager_ids` proc (no-op by default, or
91
+ # `Moderate.blocked_ids_for(user)` when the moderate gem is wired in) and
92
+ # always returns something usable in a `WHERE id IN (...)` — an Array of
93
+ # ids or an AR relation selecting ids.
94
+ def blocked_ids_for(messager)
95
+ return [] if messager.nil?
96
+
97
+ config.blocked_messager_ids.call(messager) || []
98
+ end
99
+
100
+ # True when +a+ and +b+ can't message each other (either one blocked the
101
+ # other — blocking is enforced bidirectionally, like every serious
102
+ # messaging product). Only meaningful between messagers of the same class
103
+ # (a User blocks a User); cross-class pairs are never considered blocked.
104
+ def blocked_between?(a, b)
105
+ return false if a.nil? || b.nil?
106
+ return false unless a.class.base_class == b.class.base_class
107
+
108
+ blocked_ids = blocked_ids_for(a)
109
+ if blocked_ids.respond_to?(:exists?)
110
+ # An AR relation: resolve with one indexed query instead of loading ids.
111
+ blocked_ids.exists?(b.id)
112
+ else
113
+ blocked_ids.include?(b.id)
114
+ end
115
+ end
116
+
117
+ # Host policy on top of (never instead of) block enforcement. The blocked
118
+ # check is hardcoded in the models so a host overriding `can_message`
119
+ # cannot accidentally disable Trust & Safety guarantees.
120
+ def can_message?(sender, recipient)
121
+ return false if blocked_between?(sender, recipient)
122
+
123
+ config.can_message.call(sender, recipient)
124
+ end
125
+
126
+ # Fire a domain event through the host's notifier hook (no-op by default).
127
+ # Events (see Chats::Configuration#notifier):
128
+ # :message_created message: (every persisted, non-system message)
129
+ # :participant_added participant: (someone added to a group)
130
+ #
131
+ # Hosts typically point this at a Noticed notifier or a mailer job:
132
+ # config.notifier = ->(event, **payload) {
133
+ # NewMessageNotifier.with(**payload).deliver if event == :message_created
134
+ # }
135
+ def notify(event, **payload)
136
+ config.notifier.call(event, **payload)
137
+ rescue StandardError => e
138
+ # A broken notifier must never break message delivery itself — the
139
+ # message is already committed; notifications are best-effort fan-out.
140
+ # Same error-isolation philosophy as pricing_plans' lifecycle callbacks.
141
+ logger&.error("[chats] notifier raised on #{event}: #{e.class}: #{e.message}")
142
+ nil
143
+ end
144
+
145
+ # --- Display helpers (used by the bundled views) --------------------------
146
+
147
+ def display_name_for(messager)
148
+ return "" if messager.nil?
149
+
150
+ config.messager_display_name.call(messager).to_s
151
+ end
152
+
153
+ def avatar_for(messager)
154
+ return nil if messager.nil?
155
+
156
+ config.messager_avatar.call(messager)
157
+ end
158
+
159
+ # --- Internals ------------------------------------------------------------
160
+
161
+ def logger
162
+ defined?(::Rails) ? ::Rails.logger : nil
163
+ end
164
+
165
+ # A stable, URL-safe, non-guessy key for a messager, used in DOM data
166
+ # attributes (the Stimulus thread controller compares it to decide
167
+ # own-vs-other bubble alignment) and in direct-conversation keys.
168
+ # GlobalID params are opaque-ish (Base64) and already encode class + id.
169
+ def messager_key(messager)
170
+ messager.to_global_id.to_param
171
+ end
172
+
173
+ private
174
+
175
+ def registered_class?(registry, klass)
176
+ klass = klass.class unless klass.is_a?(Class) || klass.is_a?(String)
177
+ name = klass.is_a?(String) ? klass : klass.name
178
+ return true if registry.include?(name)
179
+
180
+ # Ancestor-aware fallback: accept subclasses of registered classes
181
+ # (e.g. an STI `Admin < User` when `User` is the registered messager).
182
+ constant = klass.is_a?(String) ? name.safe_constantize : klass
183
+ return false unless constant.respond_to?(:ancestors)
184
+
185
+ constant.ancestors.any? { |ancestor| registry.include?(ancestor.name) }
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Chats
7
+ module Generators
8
+ # `rails generate chats:install` — copies the adaptive migration (uuid or
9
+ # bigint keys, adapter-aware JSON columns) and the annotated initializer,
10
+ # then prints the remaining setup steps.
11
+ class InstallGenerator < Rails::Generators::Base
12
+ include ActiveRecord::Generators::Migration
13
+
14
+ source_root File.expand_path("templates", __dir__)
15
+ desc "Install chats migrations and initializer"
16
+
17
+ def self.next_migration_number(dir)
18
+ ActiveRecord::Generators::Base.next_migration_number(dir)
19
+ end
20
+
21
+ def create_migration_file
22
+ migration_template "create_chats_tables.rb.erb", File.join(db_migrate_path, "create_chats_tables.rb")
23
+ end
24
+
25
+ def create_initializer
26
+ template "initializer.rb", "config/initializers/chats.rb"
27
+ end
28
+
29
+ def display_post_install_message
30
+ say "\n💬 The `chats` gem has been installed.", :green
31
+ say "\nTo complete the setup:"
32
+
33
+ say " 1. Run 'rails db:migrate' to create the chats tables."
34
+ say " ⚠️ You must run migrations before starting your app!", :yellow
35
+
36
+ say " 2. Make your users conversational:"
37
+ say " class User < ApplicationRecord"
38
+ say " acts_as_messager"
39
+ say " end"
40
+
41
+ say " 3. Mount the inbox wherever you want it to live:"
42
+ say " # config/routes.rb"
43
+ say " mount Chats::Engine => \"/messages\""
44
+
45
+ say " 4. (Optional) Attach conversations to your domain:"
46
+ say " class Ride < ApplicationRecord"
47
+ say " acts_as_chat_subject"
48
+ say " end"
49
+ say " user.chat_with(driver, about: ride)"
50
+
51
+ say "\nYou now have real-time DMs: inbox, threads, reactions, read receipts. 🚀"
52
+ say "Pure Hotwire — works out of the box with importmaps + the default Stimulus setup.\n", :green
53
+ end
54
+
55
+ private
56
+
57
+ def migration_version
58
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # chats_conversations
9
+ #
10
+ # A direct (1:1) or group thread, optionally *about* a polymorphic host
11
+ # record (subject: a ride, an order, a listing…). `last_message_at` /
12
+ # `last_message_id` / `messages_count` are denormalized so the inbox is a
13
+ # single indexed ORDER BY with no MAX() subqueries.
14
+ # ---------------------------------------------------------------------------
15
+ create_table :chats_conversations, id: primary_key_type do |t|
16
+ t.string :kind, null: false, default: "direct"
17
+ t.string :title
18
+
19
+ # What the conversation is about (optional). `index: false` because we
20
+ # declare the polymorphic index explicitly below with a stable name;
21
+ # without it, `t.references` would ALSO auto-create one and the two
22
+ # would collide ("index ... already exists") when the migration runs.
23
+ t.references :subject, polymorphic: true, type: foreign_key_type, null: true, index: false
24
+
25
+ # Deterministic identity for direct threads ("sorted pair of messager
26
+ # keys [+ subject]"). The UNIQUE index is what makes concurrent
27
+ # find-or-create race-safe (create_or_find_by! resolves collisions
28
+ # through it). NULL for groups — multiple NULLs are allowed in unique
29
+ # indexes on every supported adapter.
30
+ t.string :direct_key
31
+
32
+ # Inbox denormalization. last_message_id has NO foreign key on purpose:
33
+ # a circular conversations<->messages FK pair makes row deletion
34
+ # order-dependent (you couldn't delete either row first). The column is
35
+ # best-effort bookkeeping, healed by the model when messages vanish.
36
+ t.datetime :last_message_at
37
+ t.column :last_message_id, foreign_key_type
38
+ t.integer :messages_count, null: false, default: 0
39
+
40
+ t.timestamps
41
+ end
42
+
43
+ add_index :chats_conversations, [ :subject_type, :subject_id ], name: "index_chats_conversations_on_subject"
44
+ add_index :chats_conversations, :direct_key, unique: true, name: "index_chats_conversations_on_direct_key"
45
+ add_index :chats_conversations, :last_message_at, name: "index_chats_conversations_on_last_message_at"
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # chats_participants
49
+ #
50
+ # A messager's seat in a conversation, holding ALL per-member state:
51
+ # role, read horizon (last_read_at — there is deliberately no per-message
52
+ # receipts table; see Chats::Participant), mute, soft-leave, and the
53
+ # debounced-notification bookkeeping (last_notified_at).
54
+ # ---------------------------------------------------------------------------
55
+ create_table :chats_participants, id: primary_key_type do |t|
56
+ t.references :conversation, null: false, type: foreign_key_type,
57
+ foreign_key: { to_table: :chats_conversations }, index: false
58
+ t.references :messager, polymorphic: true, null: false, type: foreign_key_type, index: false
59
+
60
+ t.string :role, null: false, default: "member"
61
+ t.datetime :last_read_at
62
+ t.datetime :muted_at
63
+ t.datetime :left_at
64
+ t.datetime :last_notified_at
65
+
66
+ t.timestamps
67
+ end
68
+
69
+ # One seat per messager per conversation — also the lock that makes
70
+ # concurrent add_participant! idempotent.
71
+ add_index :chats_participants, [ :conversation_id, :messager_type, :messager_id ],
72
+ unique: true, name: "index_chats_participants_uniqueness"
73
+ # The inbox entry point: "all of MY participations" (then join
74
+ # conversations ordered by last_message_at).
75
+ add_index :chats_participants, [ :messager_type, :messager_id ], name: "index_chats_participants_on_messager"
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # chats_messages
79
+ #
80
+ # kind: "text" (human, has sender) | "system" (posted by the host app —
81
+ # "Your ride was cancelled" — no sender). Soft deletion keeps a tombstone
82
+ # (deleted_at set, body cleared) for thread continuity + T&S evidence.
83
+ # sender is nullable: system messages have none, and destroyed messager
84
+ # accounts nullify theirs (history survives account deletion).
85
+ # ---------------------------------------------------------------------------
86
+ create_table :chats_messages, id: primary_key_type do |t|
87
+ t.references :conversation, null: false, type: foreign_key_type,
88
+ foreign_key: { to_table: :chats_conversations }, index: false
89
+ t.references :sender, polymorphic: true, null: true, type: foreign_key_type, index: false
90
+
91
+ t.string :kind, null: false, default: "text"
92
+ t.text :body
93
+ t.references :reply_to, type: foreign_key_type, null: true,
94
+ foreign_key: { to_table: :chats_messages }, index: false
95
+ t.datetime :edited_at
96
+ t.datetime :deleted_at
97
+ t.send(json_column_type, :metadata, default: json_column_default)
98
+
99
+ t.timestamps
100
+ end
101
+
102
+ # THE hot path: a conversation's message page, keyset-paginated on
103
+ # (created_at, id) — see Chats::Message.before_message.
104
+ add_index :chats_messages, [ :conversation_id, :created_at, :id ], name: "index_chats_messages_on_conversation_and_created_at"
105
+ add_index :chats_messages, [ :sender_type, :sender_id ], name: "index_chats_messages_on_sender"
106
+ add_index :chats_messages, :reply_to_id, name: "index_chats_messages_on_reply_to_id"
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # chats_reactions
110
+ #
111
+ # Emoji reactions; the unique index makes tap-to-toggle race-safe.
112
+ # ---------------------------------------------------------------------------
113
+ create_table :chats_reactions, id: primary_key_type do |t|
114
+ t.references :message, null: false, type: foreign_key_type,
115
+ foreign_key: { to_table: :chats_messages }, index: false
116
+ t.references :reactor, polymorphic: true, null: false, type: foreign_key_type, index: false
117
+ t.string :emoji, null: false
118
+
119
+ t.timestamps
120
+ end
121
+
122
+ add_index :chats_reactions, [ :message_id, :reactor_type, :reactor_id, :emoji ],
123
+ unique: true, name: "index_chats_reactions_uniqueness"
124
+
125
+ # NOTE: value-list vocabularies (kind, role) are validated in the MODELS
126
+ # (frozen constants + inclusion validations), NOT by DB check constraints —
127
+ # so the gem can grow its taxonomy without shipping a migration to widen a
128
+ # CHECK. Same rationale as the rest of the gem ecosystem (moderate, …).
129
+ end
130
+
131
+ private
132
+
133
+ # Honor the host's configured primary key type (uuid vs bigint). Reads the
134
+ # same setting `rails g model` uses, so an app generated with
135
+ # `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
136
+ # gets uuid chats tables and uuid foreign keys, automatically.
137
+ def primary_and_foreign_key_types
138
+ config = Rails.configuration.generators
139
+ setting = config.options[config.orm][:primary_key_type]
140
+ primary_key_type = setting || :primary_key
141
+ foreign_key_type = setting || :bigint
142
+ [primary_key_type, foreign_key_type]
143
+ end
144
+
145
+ def json_column_type
146
+ return :jsonb if connection.adapter_name.downcase.include?("postgresql")
147
+
148
+ :json
149
+ end
150
+
151
+ # MySQL 8+ doesn't allow default values on JSON columns.
152
+ # Returns an empty-hash default for SQLite/PostgreSQL, nil for MySQL.
153
+ # The model handles nil metadata gracefully (attribute default {}).
154
+ def json_column_default
155
+ return nil if connection.adapter_name.downcase.include?("mysql")
156
+
157
+ {}
158
+ end
159
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ Chats.configure do |config|
4
+ # ==========================================================================
5
+ # WHO CONVERSES?
6
+ # ==========================================================================
7
+ #
8
+ # The model that opens conversations and sends messages — the one with
9
+ # `acts_as_messager`. Participants are polymorphic, so OTHER models with
10
+ # `acts_as_messager` can join conversations too; this names the primary one
11
+ # the engine's controllers resolve against. Stored as a string and resolved
12
+ # lazily, so it works no matter when your app boots.
13
+ #
14
+ # Default: "User"
15
+ config.messager_class = "User"
16
+
17
+ # ==========================================================================
18
+ # CONTROLLER INTEGRATION
19
+ # ==========================================================================
20
+ #
21
+ # The engine inherits from your controller, so your layout, helpers, locale
22
+ # switching, and auth all apply to the chat screens automatically.
23
+ #
24
+ # config.parent_controller = "::ApplicationController"
25
+ #
26
+ # How the engine finds the current messager and requires login. The
27
+ # defaults work with Devise out of the box.
28
+ #
29
+ # config.current_messager_method = :current_user
30
+ # config.authenticate_method = :authenticate_user!
31
+ #
32
+ # Render the chat screens with a specific layout (nil inherits whatever
33
+ # your parent controller uses):
34
+ #
35
+ # config.layout = "application"
36
+
37
+ # ==========================================================================
38
+ # FEATURES — everything on by default; switch off what you don't want
39
+ # ==========================================================================
40
+ #
41
+ # config.groups = true # group conversations (3+ people)
42
+ # config.reactions = true # emoji reactions on messages
43
+ # config.read_receipts = true # "Seen" + unread tracking
44
+ # config.typing_indicators = true # live "X is typing…"
45
+ # config.editing = true # senders can edit their messages
46
+ # config.deletion = :soft # :soft (tombstone) | :hard | false
47
+ # config.attachments = :images # false | :images | :any (ActiveStorage)
48
+ # config.search = true # inbox search box
49
+
50
+ # ==========================================================================
51
+ # LIMITS
52
+ # ==========================================================================
53
+ #
54
+ # config.messages_per_page = 30
55
+ # config.max_message_length = 5_000
56
+ # config.max_group_size = 32
57
+ # config.max_attachment_size = 10.megabytes
58
+ # config.max_attachments_per_message = 4
59
+ #
60
+ # Per-sender send throttle (enforced with Rails 8's controller rate_limit;
61
+ # a no-op on Rails 7.x). Set to nil to disable.
62
+ #
63
+ # config.send_rate_limit = { to: 60, within: 1.minute }
64
+ #
65
+ # Encrypt message bodies at rest (ActiveRecord Encryption; requires
66
+ # `bin/rails db:encryption:init`). Body search degrades when enabled.
67
+ #
68
+ # config.encrypt_messages = false
69
+
70
+ # ==========================================================================
71
+ # POLICIES — who may talk to whom
72
+ # ==========================================================================
73
+ #
74
+ # Runs ON TOP of block enforcement (a blocked pair can never talk, no
75
+ # matter what this returns). Default: anyone can message anyone — scope it
76
+ # to your domain, e.g. "only people who share an accepted ride":
77
+ #
78
+ # config.can_message = ->(sender, recipient) {
79
+ # sender.shares_a_ride_with?(recipient)
80
+ # }
81
+ #
82
+ # config.can_create_group = ->(creator) { creator.admin? }
83
+
84
+ # ==========================================================================
85
+ # TRUST & SAFETY — snap onto the `moderate` gem (or anything else)
86
+ # ==========================================================================
87
+ #
88
+ # One line wires bidirectional block enforcement into conversation
89
+ # creation, message sends, inbox visibility, and unread counts:
90
+ #
91
+ # config.blocked_messager_ids = ->(user) { Moderate.blocked_ids_for(user) }
92
+ #
93
+ # To also make messages reportable and filtered, declare it where you
94
+ # configure moderate (an after-boot hook so model macros apply on reload):
95
+ #
96
+ # Rails.application.config.to_prepare do
97
+ # Chats::Message.has_reportable_content :body
98
+ # Chats::Message.moderates :body, mode: :flag # never block mid-conversation
99
+ # end
100
+ #
101
+ # …and register the filter policy in config/initializers/moderate.rb:
102
+ #
103
+ # config.filter "Chats::Message", :body, mode: :flag
104
+
105
+ # ==========================================================================
106
+ # NOTIFICATIONS — one hook, fan out anywhere
107
+ # ==========================================================================
108
+ #
109
+ # Called on notification-worthy domain moments. Keep it fast (enqueue jobs,
110
+ # don't do work inline). Events:
111
+ #
112
+ # :message_created message: every persisted human message
113
+ # :participant_added participant: someone added to a group
114
+ #
115
+ # With Noticed:
116
+ # config.notifier = ->(event, **payload) {
117
+ # NewMessageNotifier.with(**payload).deliver if event == :message_created
118
+ # }
119
+ #
120
+ # With a plain debounced-email job (see Chats::Participant#should_notify?
121
+ # for the "only email once until they come back" etiquette helper):
122
+ # config.notifier = ->(event, message:, **) {
123
+ # ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) if event == :message_created
124
+ # }
125
+
126
+ # ==========================================================================
127
+ # DISPLAY — how messagers appear in the bundled views
128
+ # ==========================================================================
129
+ #
130
+ # config.messager_display_name = ->(messager) { messager.display_name }
131
+ #
132
+ # Return anything image_tag accepts (URL, ActiveStorage attachment or
133
+ # variant), or nil for an initials placeholder:
134
+ #
135
+ # config.messager_avatar = ->(messager) {
136
+ # messager.avatar.attached? ? messager.avatar.variant(:thumb) : nil
137
+ # }
138
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Chats
6
+ module Generators
7
+ # `rails generate chats:views` — eject the engine's overridable templates
8
+ # into the HOST app so they can be restyled. This is the Devise move
9
+ # (`rails g devise:views`), and it works for the same boring Rails reason:
10
+ # the host app's `app/views` sits AHEAD of any engine's view paths in the
11
+ # lookup chain, so a file copied to e.g.
12
+ # `app/views/chats/messages/_message.html.erb` SHADOWS the gem's bundled
13
+ # default automatically — no config, no registration. Delete your copy
14
+ # and the gem's default comes back. Upgrade the gem and your ejected
15
+ # copies are untouched (re-run only if you WANT the new defaults).
16
+ #
17
+ # `source_root` points at the engine's own `app/views`, so `directory`
18
+ # copies the exact templates the engine renders.
19
+ class ViewsGenerator < Rails::Generators::Base
20
+ source_root File.expand_path("../../../app/views", __dir__)
21
+
22
+ desc "Copy chats' overridable views into your app so you can restyle them."
23
+
24
+ # Which groups to eject. Default copies everything renderable.
25
+ class_option :views,
26
+ type: :array,
27
+ default: %w[conversations messages shared],
28
+ desc: "Which view groups to copy (conversations, messages, shared)"
29
+
30
+ def copy_views
31
+ directory "chats/conversations", "app/views/chats/conversations" if include?("conversations")
32
+ directory "chats/messages", "app/views/chats/messages" if include?("messages")
33
+ directory "chats/shared", "app/views/chats/shared" if include?("shared")
34
+ end
35
+
36
+ def show_styling_tip
37
+ say "\n🎨 Views copied. They render with the gem's bundled chats.css by default;"
38
+ say " restyle freely — if your app uses Tailwind, classes you add here are"
39
+ say " picked up by your build automatically (the files now live in app/views)."
40
+ end
41
+
42
+ private
43
+
44
+ def include?(group)
45
+ options[:views].map(&:to_s).include?(group)
46
+ end
47
+ end
48
+ end
49
+ end