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
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
|