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
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
|
+
[](https://badge.fury.io/rb/chats) [](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
|