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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d7e254b33cba3c656807db12e1ca491be50335584cd5824b32d7514ffcacc1bf
4
+ data.tar.gz: 227ad7e2280ad374bc9cb1bf48ff67d164a2ad0893e9be676dfc2a3af0bcfe67
5
+ SHA512:
6
+ metadata.gz: feb3c7419b117cd252b35c4c3202ec1ab5dfb85aa3c9da26488d490102cabc33c177ea03d2cbad1251d1af29405e023718552f1a210fb8f69207e2657d8b940b
7
+ data.tar.gz: f68dc5a7814bbe56f8bad71e3495c5c1d4258fcc364e284fb118b9faee455124e041612eed1f4f38ed43a255513501430e0b5d6aa13646cc8e8438979598b4c7
data/.rubocop.yml ADDED
@@ -0,0 +1,43 @@
1
+ # MERGE our excludes with rubocop's defaults (vendor/, node_modules/, …) —
2
+ # a bare Exclude key would REPLACE them.
3
+ inherit_mode:
4
+ merge:
5
+ - Exclude
6
+
7
+ AllCops:
8
+ TargetRubyVersion: 3.2
9
+ Exclude:
10
+ # The dummy host app mirrors GENERATED code (its chats migration is a
11
+ # copy of the install template, which emits Rails-omakase array spacing
12
+ # so installs are rubocop-clean in stock Rails apps) — don't lint it
13
+ # against the gem's own style.
14
+ - test/dummy/db/**/*
15
+ # Appraisal-generated Gemfiles.
16
+ - gemfiles/*
17
+
18
+ Style/StringLiterals:
19
+ EnforcedStyle: double_quotes
20
+
21
+ Style/StringLiteralsInInterpolation:
22
+ EnforcedStyle: double_quotes
23
+
24
+ # Hard size metrics fight readable, well-commented domain code and thorough
25
+ # test classes; we optimize for the latter.
26
+ Metrics:
27
+ Enabled: false
28
+
29
+ # `a` / `b` are the natural names for a symmetric pair of messagers
30
+ # (direct_between!(a, b), blocked_between?(a, b)).
31
+ Naming/MethodParameterName:
32
+ AllowedNames: [a, b, at, id, to]
33
+
34
+ # The moderate-gem duck-typed contract fixes keyword names (field:) even
35
+ # where an implementation doesn't use them — renaming to _field would change
36
+ # the keyword and break the contract.
37
+ Lint/UnusedMethodArgument:
38
+ AllowUnusedKeywordArguments: true
39
+
40
+ Layout/LineLength:
41
+ Max: 120
42
+ Exclude:
43
+ - chats.gemspec # the long-form rubygems description is one line by design
data/.simplecov ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SimpleCov configuration file (auto-loaded before test suite)
4
+ # This keeps test_helper.rb clean and follows best practices.
5
+ # Coherent with the rest of the gem ecosystem (moderate, usage_credits, pricing_plans, …).
6
+
7
+ SimpleCov.start do
8
+ # Use SimpleFormatter for terminal-only output (no HTML generation)
9
+ formatter SimpleCov::Formatter::SimpleFormatter
10
+
11
+ # Don't count the test suite itself toward coverage
12
+ add_filter "/test/"
13
+
14
+ # Don't count code that ISN'T unit-testable by this suite and would only
15
+ # distort the numbers:
16
+ # - Generators + their templates: these run via `rails generate
17
+ # chats:install` / `chats:views` in a real host. The generator classes ARE
18
+ # exercised by Rails::Generators::TestCase, but the migration .erb itself
19
+ # is never loaded as Ruby here (the dummy migrates a copy of it instead).
20
+ # - version.rb: a single constant; nothing to cover.
21
+ add_filter "/lib/generators/"
22
+ add_filter "/lib/chats/version.rb"
23
+
24
+ # Track Ruby files in the lib directory (gem source code)
25
+ track_files "lib/**/*.rb"
26
+
27
+ # Enable branch coverage for more detailed metrics
28
+ enable_coverage :branch
29
+
30
+ # Minimum coverage thresholds to prevent coverage REGRESSION. The primitives
31
+ # (models, concerns, configuration, the facade) are thoroughly covered by the
32
+ # unit suite; controllers and the broadcast paths are driven by the
33
+ # integration tests. The thresholds sit just under the current floor so the
34
+ # gate catches a real regression without failing on the existing baseline;
35
+ # raise them as coverage grows.
36
+ minimum_coverage line: 80, branch: 60
37
+
38
+ # Disambiguate parallel test runs
39
+ command_name "Job #{ENV["TEST_ENV_NUMBER"]}" if ENV["TEST_ENV_NUMBER"]
40
+ end
41
+
42
+ # Print coverage summary to terminal after tests complete
43
+ SimpleCov.at_exit do
44
+ SimpleCov.result.format!
45
+ puts "\n#{"=" * 60}"
46
+ puts "COVERAGE SUMMARY"
47
+ puts "=" * 60
48
+ puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
49
+ branch_coverage = SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || "N/A"
50
+ puts "Branch Coverage: #{branch_coverage}%"
51
+ puts "=" * 60
52
+ end
data/AGENTS.md ADDED
@@ -0,0 +1,5 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project, and `docs/PRD.md` for the original product requirements (note: the PRD predates the extraction of the `moderate` gem — where it says `Moderation::`, the real, current API is `Moderate::`; the README and the code are the source of truth).
data/Appraisals ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Test the minimum supported Rails version (matches the gemspec floor). The
4
+ # adaptive migration template and the `rate_limit` guard (a Rails 8.0+ API,
5
+ # feature-detected in Chats::MessagesController) must both work here.
6
+ appraise "rails-7.1" do
7
+ gem "rails", "~> 7.1.0"
8
+ end
9
+
10
+ appraise "rails-7.2" do
11
+ gem "rails", "~> 7.2.0"
12
+ end
13
+
14
+ # Test the latest Rails version — this is the default/main Gemfile anyway.
15
+ appraise "rails-8.1" do
16
+ gem "rails", "~> 8.1.0"
17
+ end
data/CHANGELOG.md ADDED
@@ -0,0 +1,74 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.1] - 2026-06-10
8
+
9
+ Reliability + UX patterns adopted after a deep review of Basecamp's
10
+ open-source Campfire (https://github.com/basecamp/once-campfire) — see
11
+ `docs/campfire_review.md` for the full adopt/skip ledger:
12
+
13
+ ### Added
14
+ - **Telegram-style long-press message actions**: nothing actionable
15
+ renders inline on bubbles anymore. Long-press (or right-click) lifts a
16
+ clone of the bubble to the center over a blurred glass backdrop, with
17
+ the reactions pill above and the contextual menu below (Copy · Edit ·
18
+ Delete in red — plus whatever the host's ejected views inject, e.g. a
19
+ report link). Menu content ships per-bubble as an inert
20
+ `<template data-chats-message-menu>`; `data-chats-own-only` items are
21
+ stripped for foreign messages (cosmetic — the server still authorizes).
22
+ - **Composer edit mode**: the long-press Edit closes the popup (the bubble
23
+ morphs back home) and loads the body into the composer under a
24
+ quote-style "Edit message" cue (left accent border, one-line trimmed
25
+ original, ✕ to cancel); the SAME form re-targets to the message's
26
+ update URL with `_method=patch`. The old in-bubble edit form (GET
27
+ `/messages/:id/edit`, `_edit_form`) is REMOVED — update failures now
28
+ render into the composer's error slot. New locale keys:
29
+ `chats.message.copy/.copied`, `chats.composer.editing/.cancel_edit`.
30
+ - **Stale-thread catch-up**: `GET /:id/refresh?since=ms` appends messages
31
+ created — and replaces ones edited/tombstoned — while the client was
32
+ asleep; answers deep backlogs with a Turbo 8 page refresh instead of
33
+ splicing. The thread controller calls it when the tab wakes after 60s+
34
+ hidden and whenever the Turbo Stream subscription reconnects (observed
35
+ via turbo-rails' `connected` attribute on the stream source — no extra
36
+ Action Cable channel). Mobile WebViews suspend sockets aggressively;
37
+ without this a backgrounded chat silently loses messages.
38
+ - **«New messages» divider**: thread open renders a separator before the
39
+ first unread bubble (computed before `read!` advances the horizon).
40
+ New locale key: `chats.thread.new_messages`.
41
+ - **`:conversation_read` notifier event**: fired from `Participant#read!`
42
+ when the horizon actually consumes unread content (`conversation:`,
43
+ `participant:` payload) — lets hosts keep external notification
44
+ surfaces (bells, badges) truthful the moment a thread is read.
45
+ - **DOM budget**: the thread caps rendered bubbles (~300) in long live
46
+ sessions, trimming oldest only while parked at the bottom and
47
+ re-planting the keyset pagination anchor so trimmed history stays
48
+ reachable on scroll-up.
49
+ - **Chronology guard**: out-of-order broadcast appends (concurrent host
50
+ job workers) are re-slotted into timestamp order client-side.
51
+
52
+ ### Changed
53
+ - The recommended `config.notifier` signature is `->(event, **payload)`;
54
+ events now carry different payloads (`:message_created` → `message:`,
55
+ `:conversation_read` → `conversation:, participant:`). Keyword-specific
56
+ lambdas keep working for `:message_created` but log a harmless,
57
+ error-isolated complaint on other events.
58
+
59
+ ## [0.1.0] - 2026-06-09
60
+
61
+ Initial release. A drop-in, real-time messaging engine for Rails 7.1+ (built for the Rails 8 omakase):
62
+
63
+ - Direct (1:1) and group conversations, polymorphic participants via `acts_as_messager`
64
+ - Conversations attachable to any domain record via `acts_as_chat_subject` (`about:`)
65
+ - Real-time everything over Turbo Streams + Action Cable: message append/edit/delete, Turbo 8 inbox refreshes, live unread badges, typing indicators (custom stream action), read receipts ("Seen")
66
+ - Read state as a per-participant horizon (no per-message receipts table)
67
+ - Race-safe direct-conversation identity (`direct_key` + `create_or_find_by!`)
68
+ - Image/any attachments (ActiveStorage), emoji reactions, message editing, soft-delete tombstones, system messages (`post_system_message!`)
69
+ - Inbox with search, previews, unread counts; keyset-paginated infinite scroll-up
70
+ - Block enforcement seam (`blocked_messager_ids`) hardcoded beneath the `can_message` policy; full duck-typed reportable contract for the `moderate` gem
71
+ - Notifier hook (`config.notifier`) + notification-etiquette helpers (`notifiable_for?`, `should_notify?`, `mark_notified!`)
72
+ - Per-sender rate limiting (Rails 8 `rate_limit`, feature-detected), optional encryption at rest
73
+ - Adaptive install generator (uuid/bigint primary keys; postgres/mysql/sqlite JSON handling), Devise-style views ejector
74
+ - Two self-registering Stimulus controllers (importmap pins under `controllers/chats/*`), bundled CSS-variable-themed stylesheet, `en`/`es` locales
data/CLAUDE.md ADDED
@@ -0,0 +1,5 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to AI coding agents when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project, and `docs/PRD.md` for the original product requirements (note: the PRD predates the extraction of the `moderate` gem — where it says `Moderation::`, the real, current API is `Moderate::`; the README and the code are the source of truth).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Javi R
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,384 @@
1
+ # 💬 `chats` - Add user-to-user DMs & group chats to your Rails app
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/chats.svg)](https://badge.fury.io/rb/chats) [![Build Status](https://github.com/rameerez/chats/workflows/Tests/badge.svg)](https://github.com/rameerez/chats/actions)
4
+
5
+ > [!TIP]
6
+ > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=chats)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=chats)!
7
+
8
+ `chats` gives your Rails app **Instagram-class user-to-user messaging**: direct messages, group chats, image attachments, emoji reactions, read receipts, unread badges, and typing indicators — all real-time, all server-rendered.
9
+
10
+ It's **Hotwire-native**: messages stream live over Turbo Streams + Action Cable, the inbox refreshes itself with Turbo 8 morphing, and the only JavaScript is two tiny Stimulus controllers the gem ships and registers for you. No SPA, no build step, no custom WebSocket code — and everything degrades gracefully to plain request/response when WebSockets are down.
11
+
12
+ Every consumer app eventually needs DMs, and everyone rebuilds the same conversation/participant/message schema, the same Action Cable plumbing, and the same "report this message, block this user" story. `chats` is that whole rebuild, done once, done right.
13
+
14
+ ## 👨‍💻 Example
15
+
16
+ `chats` reads like plain English:
17
+
18
+ ```ruby
19
+ class User < ApplicationRecord
20
+ acts_as_messager
21
+ end
22
+
23
+ alice.message!(bob, "hola!") # DM in one line
24
+ alice.chat_with(bob) # ...or just open the conversation
25
+ alice.chat_with(bob, carol, title: "Trip") # groups
26
+ alice.unread_chats_count # for your nav badge
27
+ ```
28
+
29
+ Conversations can be *about* things in your domain:
30
+
31
+ ```ruby
32
+ class Ride < ApplicationRecord
33
+ acts_as_chat_subject
34
+ end
35
+
36
+ passenger.message!(driver, "Can I bring a suitcase?", about: ride)
37
+ ```
38
+
39
+ That `about:` gives you marketplace-style threading for free: one conversation per pair *per ride* — and the ride shows up as a context line in the inbox and the thread header.
40
+
41
+ ## Quickstart
42
+
43
+ Add the gem:
44
+
45
+ ```ruby
46
+ gem "chats"
47
+ ```
48
+
49
+ Install it (creates the migration + an initializer):
50
+
51
+ ```bash
52
+ bundle install
53
+ rails generate chats:install
54
+ rails db:migrate
55
+ ```
56
+
57
+ Make your users conversational and mount the inbox:
58
+
59
+ ```ruby
60
+ # app/models/user.rb
61
+ class User < ApplicationRecord
62
+ acts_as_messager
63
+ end
64
+
65
+ # config/routes.rb
66
+ mount Chats::Engine => "/messages"
67
+ ```
68
+
69
+ That's it. `/messages` is now a working, real-time inbox: threads, bubbles, reactions, read receipts, typing indicators. The engine inherits your `ApplicationController` (so your auth, layout, and locale apply automatically — Devise works out of the box), and its two Stimulus controllers register themselves through your existing importmap setup. Zero JavaScript changes.
70
+
71
+ Drop a "Message" button anywhere — it renders only when the viewer is allowed to message that person:
72
+
73
+ ```erb
74
+ <%= chat_button_to @driver, about: @ride %>
75
+ ```
76
+
77
+ And a live unread badge in your nav:
78
+
79
+ ```erb
80
+ <%= chats_unread_badge %>
81
+ ```
82
+
83
+ ## What `chats` does (and doesn't) do
84
+
85
+ **Does:** direct 1:1 threads, group conversations, image (or any) attachments via ActiveStorage, emoji reactions, read receipts ("Seen"), unread counts and live badges, typing indicators, message editing, soft deletion (WhatsApp-style tombstones), system messages your app posts into threads, per-sender rate limiting, inbox search, infinite scroll-up pagination, optional encryption at rest, and small adapter seams for moderation + notifications.
86
+
87
+ **Doesn't:** chatbots/LLM agents, workspaces/tenancy, voice/video, public channels, federation. It's peer-to-peer (and group) human messaging — not a Slack clone, not a support-ticketing tool.
88
+
89
+ ## 🧱 The data model
90
+
91
+ Five concepts, namespaced and polymorphic from day one (no hard `User` coupling anywhere):
92
+
93
+ - **`Chats::Conversation`** — `direct` or `group`, optionally *about* a polymorphic `subject` (a ride, an order, a listing). Denormalized `last_message_at` / `last_message_id` / `messages_count` so the inbox is one indexed query.
94
+ - **`Chats::Participant`** — a messager's seat in a conversation. Holds role, read horizon, mute, soft-leave, and notification bookkeeping.
95
+ - **`Chats::Message`** — `text` (human) or `system` (posted by your app). Soft-deletes to a tombstone. Attachments via ActiveStorage.
96
+ - **`Chats::Reaction`** — one row per (message, reactor, emoji); tap-to-toggle, race-safe.
97
+ - **Any model with `acts_as_messager`** — users, organizations, support agents: participants and senders are polymorphic.
98
+
99
+ Two deliberate design decisions worth knowing:
100
+
101
+ 1. **Read state is a horizon, not per-message receipts.** A participant has ONE `last_read_at`; a message is unread iff it's newer. That's unread counts, badges, and "Seen" indicators with zero extra writes per message (a receipts table writes N rows per message — the classic chat-schema scaling trap), and it's exactly how Basecamp's Campfire models it.
102
+ 2. **Direct conversations have a deterministic identity** (`direct_key`, unique-indexed): two people DMing each other in the same instant race into the SAME conversation, guaranteed by the database, not by hope.
103
+
104
+ ## ⚡ Real-time, the Hotwire way
105
+
106
+ - **The thread** subscribes to one conversation stream. New messages append surgically; edits/deletes replace bubbles in place; the sender's own bubble comes straight back in the form response (no cable round-trip), and Turbo's same-id dedup makes the echo broadcast a no-op.
107
+ - **Bubbles are broadcast viewer-agnostic** — one render shared by every subscriber. A tiny Stimulus controller aligns own-vs-other client-side by comparing sender keys. This is what makes single-render broadcasts possible at all.
108
+ - **The inbox** receives Turbo 8 page *refreshes* (morphing, scroll-preserving) instead of surgically patched rows: inbox rows are intensely per-viewer (unread badges, bold states, ordering), so each client re-requests and gets a correct, personalized render. Refreshes are debounced and tagged so the tab that caused the change skips its own.
109
+ - **Unread badges** get their own stream (`chats_unread_badge` helper) so any page can host a live badge without inheriting inbox refreshes.
110
+ - **Typing indicators** are a Turbo Stream *custom action* — ephemeral, nothing persisted, no Action Cable channel class, no connection identification requirements.
111
+ - **Cable down?** Everything still works request/response. Real-time is an enhancement, not a requirement.
112
+
113
+ ## 🛡️ Trust & Safety: snaps onto the [`moderate`](https://github.com/rameerez/moderate) gem
114
+
115
+ Messages are user-generated content — App Store Guideline 1.2, Google Play's UGC policy, and the EU DSA all expect **report**, **block**, and **filter** capabilities before you ship a chat. Instead of re-implementing any of that, `chats` exposes the exact seams the `moderate` gem expects, with **no hard dependency in either direction**: each gem runs standalone, and together they behave like one system. This section is the complete recipe.
116
+
117
+ ### 1. One line of blocking
118
+
119
+ ```ruby
120
+ # config/initializers/chats.rb
121
+ Chats.configure do |config|
122
+ config.blocked_messager_ids = ->(user) { Moderate.blocked_ids_for(user) }
123
+ end
124
+ ```
125
+
126
+ That single hook makes moderate's bidirectional block table the law everywhere chats makes a decision:
127
+
128
+ - a blocked pair **can't open** a conversation (`Chats::BlockedError`),
129
+ - **can't send** into an existing one (a block placed mid-conversation stops the very next message),
130
+ - and **stop seeing** each other's direct threads in the inbox and unread counts — *hidden, never deleted*: lift the block and the history reappears.
131
+
132
+ Two semantics worth knowing: blocking is enforced **beneath** your `can_message` policy (a permissive or buggy policy can never let a blocked pair talk), and **group conversations are exempt from pair blocks** — the industry standard: blocking someone removes your private line, not your seat in shared spaces. If your domain should eject blocked members from groups, do it in moderate's `on_block` hook by tearing down whatever domain relationship feeds the group membership.
133
+
134
+ ### 2. Reportable + filtered messages
135
+
136
+ ```ruby
137
+ # An after-boot hook (config.to_prepare) so the macros re-apply on every reload:
138
+ Rails.application.config.to_prepare do
139
+ Chats::Message.has_reportable_content :body, :files
140
+ Chats::Message.moderates :body, mode: :flag # text → built-in wordlist
141
+ Chats::Message.moderates :files, mode: :flag, with: :your_image_adapter
142
+ end
143
+
144
+ # config/initializers/moderate.rb — the central policy registry:
145
+ config.filter "Chats::Message", :body, mode: :flag
146
+ config.filter "Chats::Message", :files, mode: :flag, with: :your_image_adapter
147
+ ```
148
+
149
+ Use **`:flag`, never `:block`** for chat: you don't gag someone mid-conversation on a wordlist false positive. The message sends; a pending `Moderate::Flag` lands in the moderation queue for human (or ML) review.
150
+
151
+ `Chats::Message` and `Chats::Conversation` already implement moderate's full duck-typed reportable contract, so everything downstream just works:
152
+
153
+ | moderate calls… | chats answers… |
154
+ |---|---|
155
+ | `reported_owner` | the sender (who a decision notifies, who a ban targets) |
156
+ | `moderation_snapshot(:body)` | the body — frozen as evidence at report time, surviving later edits/deletes |
157
+ | `remove_reported_field!(:body)` | the **soft-delete tombstone** — a moderator's removal looks exactly like a user deletion ("Message deleted"), no special admin rendering path |
158
+ | `report_visible_to?(viewer)` | participants only (a DM isn't public content), and never the author |
159
+ | `moderation_field_value(:files)` / change detection | attachment-aware seams so image filters classify what actually changed |
160
+
161
+ ### 3. The report affordance — mind the broadcast
162
+
163
+ Put a report link on every bubble **someone else** sent. One nuance matters: `chats` renders each bubble **once per broadcast, viewer-agnostically** (that's what makes real-time fan-out cheap), so anything depending on `current_user` at render time — like moderate's `report_link` helper, which checks `report_visible_to?(viewer)` — would silently vanish from live-appended bubbles. Use the **signed-target URL** instead (viewer-independent), and hide it on own bubbles with the same client-side mechanism the gem uses for edit/delete:
164
+
165
+ ```erb
166
+ <%# in your ejected chats/messages/_message.html.erb, inside the actions row %>
167
+ <% if message.sender %>
168
+ <%= link_to "Report",
169
+ main_app.new_abuse_report_path(
170
+ target: message.to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE),
171
+ field: "body"
172
+ ),
173
+ class: "chats-message__action in-own-hidden" %>
174
+ <%# hide on .chats-message--own via your CSS; moderate's controller
175
+ re-checks report_visible_to? server-side, so hiding is cosmetic %>
176
+ <% end %>
177
+ ```
178
+
179
+ And give direct threads a **block** action in the thread menu (your ejected `show.html.erb`) pointing at your moderate-backed blocks endpoint. One UX trap: blocking hides the very thread the user is standing in — redirect to the inbox, not back.
180
+
181
+ ### 4. The admin side
182
+
183
+ Reported and auto-flagged chat messages flow into moderate's standard queues (`Moderate::Report` / `Moderate::Flag` are polymorphic) with **zero chat-specific case statements**: resolving a report with content removal calls `remove_reported_field!` → the tombstone; banning goes through your configured `ban_handler`. For browsing context, point your admin tool at `Chats::Conversation` / `Chats::Message` read-only — and if you want a "flag this while browsing" affordance, file a manual flag and jump to its queue page rather than growing enforcement buttons on the browse surface:
184
+
185
+ ```ruby
186
+ Moderate::Flag.flag!(
187
+ flaggable: message, field: "body", owner: message.reported_owner,
188
+ source: "manual", mode: "flag",
189
+ excerpt: message.body.to_s.truncate(500),
190
+ categories: ["manual_review"], scores: {}, context: { flagged_by_admin_id: admin.id }
191
+ )
192
+ ```
193
+
194
+ ### 5. Did you wire it all? The launch checklist
195
+
196
+ - [ ] `blocked_messager_ids` → `Moderate.blocked_ids_for`
197
+ - [ ] `Chats::Message` reportable (`:body`, and `:files` if attachments are on)
198
+ - [ ] body + files filter policies in `:flag` mode
199
+ - [ ] report link on foreign bubbles (signed target, broadcast-safe)
200
+ - [ ] block action on direct threads (redirecting away from the hidden thread)
201
+ - [ ] admin queue handles chat flags/reports (it does, automatically — verify with one test)
202
+ - [ ] a test that a block placed mid-conversation stops the next send
203
+
204
+ ## 🔔 Notifications: one hook, fan out anywhere
205
+
206
+ `chats` fires domain moments through a single no-op-default notifier — it does **not** build its own notification bus:
207
+
208
+ ```ruby
209
+ config.notifier = ->(event, **payload) {
210
+ case event
211
+ when :message_created
212
+ # payload: message:
213
+ NewMessageNotifier.with(record: payload[:message]).deliver # Noticed, email, push…
214
+ when :conversation_read
215
+ # payload: conversation:, participant: — fired when a read actually
216
+ # consumed unread content. Use it to keep EXTERNAL notification
217
+ # surfaces truthful: e.g. mark this chat's rows read in your
218
+ # notification center the moment the thread is read, so a bell badge
219
+ # doesn't keep advertising messages the user has already seen.
220
+ end
221
+ }
222
+ ```
223
+
224
+ > Write the lambda as `->(event, **payload)` (not `->(event, message:, **)`):
225
+ > events carry different payloads, and a keyword the event doesn't include
226
+ > would raise — harmlessly (the hook is error-isolated and logged), but
227
+ > noisily.
228
+
229
+ The etiquette helpers every messaging product needs ship on the participant, so a debounced "email me only once until I come back" digest is a tiny host job:
230
+
231
+ ```ruby
232
+ class ChatsUnreadEmailJob < ApplicationJob
233
+ def perform(message)
234
+ message.conversation.participants.active.each do |participant|
235
+ next unless participant.notifiable_for?(message) # not the sender, not muted, not departed
236
+ next unless participant.should_notify? # unread + not already notified this burst
237
+
238
+ ChatsMailer.with(participant: participant).unread_messages.deliver_now
239
+ participant.mark_notified!
240
+ end
241
+ end
242
+ end
243
+
244
+ config.notifier = ->(event, message:, **) {
245
+ ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) if event == :message_created
246
+ }
247
+ ```
248
+
249
+ And it works in the other direction too — your app can post **into** conversations:
250
+
251
+ ```ruby
252
+ ride.chat_conversations.find_each { |c| c.post_system_message!("Your ride was cancelled") }
253
+ ```
254
+
255
+ ## 🎨 Make it yours
256
+
257
+ The bundled UI is intentionally framework-free (semantic `chats-*` classes + one self-contained stylesheet, themed with CSS variables):
258
+
259
+ ```css
260
+ :root {
261
+ --chats-accent: #facc15; /* own bubbles, send button, badges */
262
+ --chats-accent-contrast: #111827;
263
+ }
264
+ ```
265
+
266
+ Want full control? Eject the views Devise-style and restyle with your own stack (Tailwind classes added there get picked up by your build, since the files live in your `app/views`):
267
+
268
+ ```bash
269
+ rails generate chats:views
270
+ ```
271
+
272
+ Override the two Stimulus controllers by pinning the same importmap keys (`controllers/chats/thread_controller`, `controllers/chats/composer_controller`) — host pins win.
273
+
274
+ ## Configuration reference
275
+
276
+ Everything lives in `config/initializers/chats.rb` (the install generator writes a fully-annotated version):
277
+
278
+ ```ruby
279
+ Chats.configure do |config|
280
+ config.messager_class = "User"
281
+
282
+ # Controller integration (Devise-compatible defaults)
283
+ config.parent_controller = "::ApplicationController"
284
+ config.current_messager_method = :current_user
285
+ config.authenticate_method = :authenticate_user!
286
+ config.layout = nil # nil inherits the parent controller's
287
+
288
+ # Features — all on by default
289
+ config.groups = true
290
+ config.reactions = true
291
+ config.read_receipts = true
292
+ config.typing_indicators = true
293
+ config.editing = true
294
+ config.deletion = :soft # :soft (tombstone) | :hard | false
295
+ config.attachments = :images # false | :images | :any
296
+ config.search = true
297
+
298
+ # Limits
299
+ config.messages_per_page = 30
300
+ config.max_message_length = 5_000
301
+ config.max_group_size = 32
302
+ config.max_attachment_size = 10.megabytes
303
+ config.max_attachments_per_message = 4
304
+ config.send_rate_limit = { to: 60, within: 1.minute } # Rails 8 rate_limit; nil disables
305
+ config.encrypt_messages = false # ActiveRecord Encryption on bodies
306
+
307
+ # Policies (on top of — never instead of — block enforcement)
308
+ config.can_message = ->(sender, recipient) { true }
309
+ config.can_create_group = ->(creator) { true }
310
+
311
+ # Ecosystem seams (no-op defaults; chats runs standalone)
312
+ config.blocked_messager_ids = ->(messager) { [] }
313
+ config.notifier = ->(event, **payload) {}
314
+
315
+ # Display (used by the bundled views)
316
+ config.messager_display_name = ->(messager) { messager.display_name }
317
+ config.messager_avatar = ->(messager) { messager.avatar } # URL/attachment/variant or nil
318
+ end
319
+ ```
320
+
321
+ ## 🤓 The full Ruby API
322
+
323
+ ```ruby
324
+ # Messagers
325
+ alice.chat_with(bob) # find-or-create the DM
326
+ alice.chat_with(bob, about: ride) # the DM about that ride
327
+ alice.chat_with(bob, carol, title: "Trip") # a group (alice is owner)
328
+ alice.message!(bob, "hi", about: ride) # send (resolves the thread)
329
+ alice.message!(conversation, "hi", files: []) # send into a conversation
330
+ alice.chats # inbox relation, newest first
331
+ alice.unread_chats_count # conversations with unread messages
332
+
333
+ # Conversations
334
+ conversation.participant?(user) # active membership
335
+ conversation.other_participants(user)
336
+ conversation.title_for(viewer) # counterpart name / group title
337
+ conversation.subject_label # "Madrid → Barcelona"
338
+ conversation.unread_count_for(user)
339
+ conversation.mark_read_by!(user)
340
+ conversation.post_system_message!("Ride cancelled")
341
+ conversation.add_participant!(user) # idempotent, race-safe
342
+
343
+ # Messages
344
+ message.edit!("fixed") # stamps edited_at
345
+ message.soft_delete! # tombstone (or destroy, per config)
346
+ message.read_by?(user)
347
+ Chats::Reaction.toggle!(message:, reactor:, emoji: "👍")
348
+
349
+ # Participants (the per-member state)
350
+ participant.read! # advance the read horizon
351
+ participant.mute! / participant.unmute!
352
+ participant.leave! # groups
353
+ participant.notifiable_for?(message) # notification etiquette
354
+ participant.should_notify? / participant.mark_notified!
355
+ ```
356
+
357
+ Errors are namespaced and meaningful: `Chats::BlockedError`, `Chats::NotAllowedError`, `Chats::ConfigurationError` — all under `Chats::Error`.
358
+
359
+ ## Database support
360
+
361
+ PostgreSQL, MySQL, and SQLite. The migration adapts automatically: it honors your app's configured primary key type (**uuid or bigint** — same detection `rails g model` uses), picks `jsonb` on Postgres / `json` elsewhere, and handles MySQL's no-defaults-on-JSON rule. Works on Rails 7.1+ and shines on the Rails 8 omakase.
362
+
363
+ ## Testing
364
+
365
+ The gem is tested with Minitest against a real dummy host app — models, broadcasts (over the Action Cable test adapter), full request cycles, generators, and every authorization negative (outsiders, leavers, and blocked pairs all get plain 404s; existence never leaks).
366
+
367
+ ```bash
368
+ bundle exec rake test # full suite
369
+ bundle exec appraisal install # then test across Rails versions:
370
+ bundle exec appraisal rails-7.1 rake test
371
+ bundle exec appraisal rails-8.1 rake test
372
+ ```
373
+
374
+ ## Development
375
+
376
+ After checking out the repo, run `bundle install`, then `bundle exec rake test`. The dummy app lives in `test/dummy` and mounts the engine at `/messages` exactly like a real host.
377
+
378
+ ## Contributing
379
+
380
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/chats. Our code of conduct is: just be nice and make your mom proud of what you do and post online.
381
+
382
+ ## License
383
+
384
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "bundler/setup"
5
+ rescue LoadError
6
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
7
+ end
8
+
9
+ require "bundler/gem_tasks"
10
+
11
+ require "rdoc/task"
12
+
13
+ RDoc::Task.new(:rdoc) do |rdoc|
14
+ rdoc.rdoc_dir = "rdoc"
15
+ rdoc.title = "Chats"
16
+ rdoc.options << "--line-numbers"
17
+ rdoc.rdoc_files.include("README.md")
18
+ rdoc.rdoc_files.include("lib/**/*.rb")
19
+ end
20
+
21
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
22
+ load "rails/tasks/engine.rake"
23
+
24
+ require "rake/testtask"
25
+
26
+ Rake::TestTask.new(:test) do |t|
27
+ t.libs << "test"
28
+ t.pattern = "test/**/*_test.rb"
29
+ t.verbose = false
30
+ end
31
+
32
+ task default: :test