Project

chats

0.0
The project is in a healthy, maintained state
chats is a drop-in gem to add chat messaging to your Rails app. It implements a real-time messaging engine for Ruby on Rails 8+ apps: direct messages (DMs), group chats, image attachments, emoji reactions, read receipts, unread badges, and typing indicators. All rendered server-side and updated live with Hotwire (Turbo Streams over Action Cable), so it ships with zero custom JavaScript build steps and works with importmaps out of the box. Any model can converse via a single `acts_as_messager` macro (users, organizations, support agents β€” participants are polymorphic), conversations can be optionally attached to any domain record via `acts_as_chat_subject` (so users can chat about a specific order, a listing, a booking), and the whole inbox UI is overridable view-by-view like Devise. It exposes small adapter seams a blocked-users lookup, a `can_message` policy, and a notifier hook; so it snaps onto the `moderate` gem for Trust & Safety (report/block/filter, DSA + app-store compliance) and onto any notification system (like the `noticed` gem) without any hard dependencies. Messages support soft deletion, editing, system messages posted by your app, per-sender rate limiting, and optional encryption at rest.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7.1.0, < 9.0
>= 7.1.0, < 9.0
~> 1.0
>= 7.1.0, < 9.0
 Project Readme

πŸ’¬ chats - Add user-to-user DMs & group chats to your Rails app

Gem Version Build Status

Tip

πŸš€ Ship your next Rails app 10x faster! I've built RailsFast, 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!

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.

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.

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.

πŸ‘¨β€πŸ’» Example

chats reads like plain English:

class User < ApplicationRecord
  acts_as_messager
end

alice.message!(bob, "hola!")              # DM in one line
alice.chat_with(bob)                       # ...or just open the conversation
alice.chat_with(bob, carol, title: "Trip") # groups
alice.unread_chats_count                   # for your nav badge

Conversations can be about things in your domain:

class Ride < ApplicationRecord
  acts_as_chat_subject
end

passenger.message!(driver, "Can I bring a suitcase?", about: ride)

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.

Quickstart

Add the gem:

gem "chats"

Install it (creates the migration + an initializer):

bundle install
rails generate chats:install
rails db:migrate

Make your users conversational and mount the inbox:

# app/models/user.rb
class User < ApplicationRecord
  acts_as_messager
end

# config/routes.rb
mount Chats::Engine => "/messages"

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.

Drop a "Message" button anywhere β€” it renders only when the viewer is allowed to message that person:

<%= chat_button_to @driver, about: @ride %>

And a live unread badge in your nav:

<%= chats_unread_badge %>

What chats does (and doesn't) do

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.

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.

🧱 The data model

Five concepts, namespaced and polymorphic from day one (no hard User coupling anywhere):

  • 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.
  • Chats::Participant β€” a messager's seat in a conversation. Holds role, read horizon, mute, soft-leave, and notification bookkeeping.
  • Chats::Message β€” text (human) or system (posted by your app). Soft-deletes to a tombstone. Attachments via ActiveStorage.
  • Chats::Reaction β€” one row per (message, reactor, emoji); tap-to-toggle, race-safe.
  • Any model with acts_as_messager β€” users, organizations, support agents: participants and senders are polymorphic.

Two deliberate design decisions worth knowing:

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

⚑ Real-time, the Hotwire way

  • 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.
  • 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.
  • 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.
  • Unread badges get their own stream (chats_unread_badge helper) so any page can host a live badge without inheriting inbox refreshes.
  • Typing indicators are a Turbo Stream custom action β€” ephemeral, nothing persisted, no Action Cable channel class, no connection identification requirements.
  • Cable down? Everything still works request/response. Real-time is an enhancement, not a requirement.

πŸ›‘οΈ Trust & Safety: snaps onto the moderate gem

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.

1. One line of blocking

# config/initializers/chats.rb
Chats.configure do |config|
  config.blocked_messager_ids = ->(user) { Moderate.blocked_ids_for(user) }
end

That single hook makes moderate's bidirectional block table the law everywhere chats makes a decision:

  • a blocked pair can't open a conversation (Chats::BlockedError),
  • can't send into an existing one (a block placed mid-conversation stops the very next message),
  • and stop seeing each other's direct threads in the inbox and unread counts β€” hidden, never deleted: lift the block and the history reappears.

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.

2. Reportable + filtered messages

# An after-boot hook (config.to_prepare) so the macros re-apply on every reload:
Rails.application.config.to_prepare do
  Chats::Message.has_reportable_content :body, :files
  Chats::Message.moderates :body, mode: :flag    # text β†’ built-in wordlist
  Chats::Message.moderates :files, mode: :flag, with: :your_image_adapter
end

# config/initializers/moderate.rb β€” the central policy registry:
config.filter "Chats::Message", :body, mode: :flag
config.filter "Chats::Message", :files, mode: :flag, with: :your_image_adapter

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.

Chats::Message and Chats::Conversation already implement moderate's full duck-typed reportable contract, so everything downstream just works:

moderate calls… chats answers…
reported_owner the sender (who a decision notifies, who a ban targets)
moderation_snapshot(:body) the body β€” frozen as evidence at report time, surviving later edits/deletes
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
report_visible_to?(viewer) participants only (a DM isn't public content), and never the author
moderation_field_value(:files) / change detection attachment-aware seams so image filters classify what actually changed

3. The report affordance β€” mind the broadcast

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:

<%# in your ejected chats/messages/_message.html.erb, inside the actions row %>
<% if message.sender %>
  <%= link_to "Report",
        main_app.new_abuse_report_path(
          target: message.to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE),
          field: "body"
        ),
        class: "chats-message__action in-own-hidden" %>
        <%# hide on .chats-message--own via your CSS; moderate's controller
            re-checks report_visible_to? server-side, so hiding is cosmetic %>
<% end %>

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.

4. The admin side

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:

Moderate::Flag.flag!(
  flaggable: message, field: "body", owner: message.reported_owner,
  source: "manual", mode: "flag",
  excerpt: message.body.to_s.truncate(500),
  categories: ["manual_review"], scores: {}, context: { flagged_by_admin_id: admin.id }
)

5. Did you wire it all? The launch checklist

  • blocked_messager_ids β†’ Moderate.blocked_ids_for
  • Chats::Message reportable (:body, and :files if attachments are on)
  • body + files filter policies in :flag mode
  • report link on foreign bubbles (signed target, broadcast-safe)
  • block action on direct threads (redirecting away from the hidden thread)
  • admin queue handles chat flags/reports (it does, automatically β€” verify with one test)
  • a test that a block placed mid-conversation stops the next send

πŸ”” Notifications: one hook, fan out anywhere

chats fires domain moments through a single no-op-default notifier β€” it does not build its own notification bus:

config.notifier = ->(event, **payload) {
  case event
  when :message_created
    # payload: message:
    NewMessageNotifier.with(record: payload[:message]).deliver  # Noticed, email, push…
  when :conversation_read
    # payload: conversation:, participant: β€” fired when a read actually
    # consumed unread content. Use it to keep EXTERNAL notification
    # surfaces truthful: e.g. mark this chat's rows read in your
    # notification center the moment the thread is read, so a bell badge
    # doesn't keep advertising messages the user has already seen.
  end
}

Write the lambda as ->(event, **payload) (not ->(event, message:, **)): events carry different payloads, and a keyword the event doesn't include would raise β€” harmlessly (the hook is error-isolated and logged), but noisily.

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:

class ChatsUnreadEmailJob < ApplicationJob
  def perform(message)
    message.conversation.participants.active.each do |participant|
      next unless participant.notifiable_for?(message) # not the sender, not muted, not departed
      next unless participant.should_notify?           # unread + not already notified this burst

      ChatsMailer.with(participant: participant).unread_messages.deliver_now
      participant.mark_notified!
    end
  end
end

config.notifier = ->(event, message:, **) {
  ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) if event == :message_created
}

And it works in the other direction too β€” your app can post into conversations:

ride.chat_conversations.find_each { |c| c.post_system_message!("Your ride was cancelled") }

🎨 Make it yours

The bundled UI is intentionally framework-free (semantic chats-* classes + one self-contained stylesheet, themed with CSS variables):

:root {
  --chats-accent: #facc15;           /* own bubbles, send button, badges */
  --chats-accent-contrast: #111827;
}

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

rails generate chats:views

Override the two Stimulus controllers by pinning the same importmap keys (controllers/chats/thread_controller, controllers/chats/composer_controller) β€” host pins win.

Configuration reference

Everything lives in config/initializers/chats.rb (the install generator writes a fully-annotated version):

Chats.configure do |config|
  config.messager_class = "User"

  # Controller integration (Devise-compatible defaults)
  config.parent_controller = "::ApplicationController"
  config.current_messager_method = :current_user
  config.authenticate_method = :authenticate_user!
  config.layout = nil                       # nil inherits the parent controller's

  # Features β€” all on by default
  config.groups = true
  config.reactions = true
  config.read_receipts = true
  config.typing_indicators = true
  config.editing = true
  config.deletion = :soft                   # :soft (tombstone) | :hard | false
  config.attachments = :images              # false | :images | :any
  config.search = true

  # Limits
  config.messages_per_page = 30
  config.max_message_length = 5_000
  config.max_group_size = 32
  config.max_attachment_size = 10.megabytes
  config.max_attachments_per_message = 4
  config.send_rate_limit = { to: 60, within: 1.minute }  # Rails 8 rate_limit; nil disables
  config.encrypt_messages = false           # ActiveRecord Encryption on bodies

  # Policies (on top of β€” never instead of β€” block enforcement)
  config.can_message = ->(sender, recipient) { true }
  config.can_create_group = ->(creator) { true }

  # Ecosystem seams (no-op defaults; chats runs standalone)
  config.blocked_messager_ids = ->(messager) { [] }
  config.notifier = ->(event, **payload) {}

  # Display (used by the bundled views)
  config.messager_display_name = ->(messager) { messager.display_name }
  config.messager_avatar = ->(messager) { messager.avatar }  # URL/attachment/variant or nil
end

πŸ€“ The full Ruby API

# Messagers
alice.chat_with(bob)                          # find-or-create the DM
alice.chat_with(bob, about: ride)             # the DM about that ride
alice.chat_with(bob, carol, title: "Trip")    # a group (alice is owner)
alice.message!(bob, "hi", about: ride)        # send (resolves the thread)
alice.message!(conversation, "hi", files: []) # send into a conversation
alice.chats                                   # inbox relation, newest first
alice.unread_chats_count                      # conversations with unread messages

# Conversations
conversation.participant?(user)               # active membership
conversation.other_participants(user)
conversation.title_for(viewer)                # counterpart name / group title
conversation.subject_label                    # "Madrid β†’ Barcelona"
conversation.unread_count_for(user)
conversation.mark_read_by!(user)
conversation.post_system_message!("Ride cancelled")
conversation.add_participant!(user)           # idempotent, race-safe

# Messages
message.edit!("fixed")                        # stamps edited_at
message.soft_delete!                          # tombstone (or destroy, per config)
message.read_by?(user)
Chats::Reaction.toggle!(message:, reactor:, emoji: "πŸ‘")

# Participants (the per-member state)
participant.read!                             # advance the read horizon
participant.mute! / participant.unmute!
participant.leave!                            # groups
participant.notifiable_for?(message)          # notification etiquette
participant.should_notify? / participant.mark_notified!

Errors are namespaced and meaningful: Chats::BlockedError, Chats::NotAllowedError, Chats::ConfigurationError β€” all under Chats::Error.

Database support

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.

Testing

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

bundle exec rake test            # full suite
bundle exec appraisal install    # then test across Rails versions:
bundle exec appraisal rails-7.1 rake test
bundle exec appraisal rails-8.1 rake test

Development

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.

Contributing

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.

License

The gem is available as open source under the terms of the MIT License.