chats 0.6.2 → 0.7.2

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -0
  3. data/README.md +90 -28
  4. data/app/assets/stylesheets/chats.css +225 -15
  5. data/app/helpers/chats/engine_helper.rb +101 -7
  6. data/app/javascript/chats/dock_controller.js +54 -33
  7. data/app/javascript/chats/sheet.js +69 -0
  8. data/app/javascript/chats/thread_controller.js +199 -5
  9. data/app/javascript/chats/tray_controller.js +506 -0
  10. data/app/views/chats/conversations/_thread.html.erb +4 -0
  11. data/app/views/chats/dock/_dock.html.erb +9 -1
  12. data/app/views/chats/messages/_message.html.erb +30 -3
  13. data/app/views/chats/tray/_badge.html.erb +12 -0
  14. data/app/views/chats/tray/_corner.html.erb +16 -0
  15. data/app/views/chats/tray/_item.html.erb +44 -0
  16. data/app/views/chats/tray/_tray.html.erb +27 -0
  17. data/config/importmap.rb +4 -0
  18. data/config/locales/en.yml +1 -0
  19. data/config/locales/es.yml +1 -0
  20. data/lib/chats/after_commit.rb +87 -0
  21. data/lib/chats/broadcasts.rb +8 -0
  22. data/lib/chats/models/participant.rb +14 -1
  23. data/lib/chats/subscribers.rb +14 -1
  24. data/lib/chats/version.rb +1 -1
  25. data/lib/generators/chats/templates/initializer.rb +1 -0
  26. metadata +8 -16
  27. data/docs/PRD.md +0 -254
  28. data/docs/campfire_review.md +0 -46
  29. data/docs/screenshots/composer-actions.png +0 -0
  30. data/docs/screenshots/dock-bar.png +0 -0
  31. data/docs/screenshots/dock-desktop.png +0 -0
  32. data/docs/screenshots/dock-phone-button.png +0 -0
  33. data/docs/screenshots/dock-phone-panel.png +0 -0
  34. data/docs/screenshots/dock-phone-window.png +0 -0
  35. data/docs/screenshots/inbox.png +0 -0
  36. data/docs/screenshots/split-inbox-empty.png +0 -0
  37. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  38. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  39. data/docs/screenshots/split-inbox.png +0 -0
  40. data/docs/screenshots/system-note-action.png +0 -0
  41. data/docs/screenshots/thread-actions-phone.png +0 -0
@@ -0,0 +1,44 @@
1
+ <%# One item of the tray (`chats_tray_item`): closed, a tab on the bottom
2
+ edge (a round button on a phone); open, a panel with the dock's skin and
3
+ the dock's bar on top (a full-screen sheet on a phone). A <details>: it
4
+ opens and closes with no JavaScript, like the dock's own panel.
5
+
6
+ data-chats-active / data-chats-minimized are the read gate chats' thread
7
+ controller obeys in a dock window: a thread inside a closed item never
8
+ marks anything read (chats--tray keeps the pair in step and dispatches
9
+ `chats:activated` when the item opens).
10
+
11
+ Locals: key, label (the closed tab's words), title (the open panel's,
12
+ HTML allowed), icon (HTML), badge (HTML: `chats_tray_badge` and the
13
+ stream that replaces it), badge_url (where a missed badge is asked
14
+ again), body (the panel's content). %>
15
+ <%= tag.details id: "chats_tray_#{key}",
16
+ class: "chats-tray__item",
17
+ data: {
18
+ "chats--tray-target": "item",
19
+ chats_tray_key: key,
20
+ chats_tray_badge_url: badge_url,
21
+ chats_active: "true",
22
+ chats_minimized: true,
23
+ action: "toggle->chats--tray#toggled"
24
+ } do %>
25
+ <%# The summary reads "Need help?, 2 unread" from its own text closed,
26
+ and the panel's title open. %>
27
+ <summary class="chats-tray__launcher">
28
+ <% if icon %><span class="chats-tray__icon" aria-hidden="true"><%= icon %></span><% end %>
29
+ <span class="chats-tray__label"><%= label %></span>
30
+ <span class="chats-tray__title"><%= title %></span>
31
+ <%= badge %>
32
+ <span class="chats-tray__chevron" aria-hidden="true">
33
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
34
+ stroke-linejoin="round" focusable="false"><path d="m6 15 6-6 6 6" /></svg>
35
+ </span>
36
+ <span class="chats-tray__close-glyph" aria-hidden="true">
37
+
38
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
39
+ focusable="false"><path d="M6 6l12 12M18 6 6 18" /></svg>
40
+ </span>
41
+ </summary>
42
+
43
+ <%= tag.div body, class: "chats-tray__body", role: "region", "aria-label": strip_tags(title.to_s).squish %>
44
+ <% end %>
@@ -0,0 +1,27 @@
1
+ <%# The tray: the items a host puts in the corner (`chats_dock { … }`, or
2
+ `chats_tray { … }` on its own), left to right in the block's order,
3
+ between the dock's windows and its panel. Each item is a
4
+ `chats_tray_item`; the chats--tray controller (tray_controller.js) runs
5
+ them all: one open at a time, the phone sheet, Escape, memory, badges.
6
+
7
+ Locals: tray (the items' HTML), owner (whose memory: the viewer's key,
8
+ or "guest"), standalone (true when there is no dock around it). %>
9
+ <% standalone = local_assigns.fetch(:standalone, false) %>
10
+ <%# The id names the owner, as the corner's and the dock's do: the tray rides
11
+ along with them from page to page (data-turbo-permanent), and on each
12
+ visit chats--tray reconciles its items with the ones the new page rendered
13
+ under the same id (by key: one still there keeps its state). %>
14
+ <%= tag.div tray,
15
+ id: "chats_tray_items_#{owner}",
16
+ class: "chats-tray",
17
+ data: {
18
+ controller: "chats--tray",
19
+ "chats--dock-target": ("tray" unless standalone),
20
+ "chats--tray-owner-value": owner,
21
+ "chats--tray-standalone-value": standalone,
22
+ action: "keydown->chats--tray#keydown " \
23
+ "turbo:before-render@document->chats--tray#reconcile " \
24
+ "turbo:before-cache@document->chats--tray#releaseSheet " \
25
+ "turbo:load@document->chats--tray#pageChanged turbo:render@document->chats--tray#pageChanged " \
26
+ "turbo:frame-load@document->chats--tray#frameLoaded"
27
+ } %>
data/config/importmap.rb CHANGED
@@ -18,8 +18,12 @@ pin "controllers/chats/refresh_inbox_controller", to: "chats/refresh_inbox_contr
18
18
  pin "controllers/chats/dock_controller", to: "chats/dock_controller.js"
19
19
  pin "controllers/chats/unread_badge_controller", to: "chats/unread_badge_controller.js"
20
20
  pin "controllers/chats/split_controller", to: "chats/split_controller.js"
21
+ pin "controllers/chats/tray_controller", to: "chats/tray_controller.js"
21
22
 
22
23
  # Not a controller: what the inbox, thread, badge and dock controllers share
23
24
  # to catch up on broadcasts they missed. Outside controllers/ so the eager
24
25
  # loader never tries to register it.
25
26
  pin "chats/recovery", to: "chats/recovery.js"
27
+ # What the dock's sheet and the tray's share: the page's inert and its scroll
28
+ # lock, held by owner.
29
+ pin "chats/sheet", to: "chats/sheet.js"
@@ -51,6 +51,7 @@ en:
51
51
  save: "Save"
52
52
  react: "React"
53
53
  toggle_reaction: "React with %{emoji}"
54
+ menu: "Message options"
54
55
  composer:
55
56
  editing: "Edit message"
56
57
  cancel_edit: "Cancel edit"
@@ -51,6 +51,7 @@ es:
51
51
  save: "Guardar"
52
52
  react: "Reaccionar"
53
53
  toggle_reaction: "Reaccionar con %{emoji}"
54
+ menu: "Opciones del mensaje"
54
55
  composer:
55
56
  editing: "Editar mensaje"
56
57
  cancel_edit: "Cancelar edición"
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # Work that must not happen before the change it announces is durable,
5
+ # owned by the TRANSACTION, the way a record's after_commit callbacks are.
6
+ #
7
+ # Chats::AfterCommit.run(connection, key: [:unread_consumed, seat.id]) { … }
8
+ #
9
+ # The block is wrapped in an Envelope and enrolled in the connection's
10
+ # current transaction (`connection.add_transaction_record`, the call Active
11
+ # Record makes for a saved record, on every supported Rails), which
12
+ # finalizes it: runs it on commit, drops it on rollback or when an exception
13
+ # ends the transaction. Nothing lives on a Ruby instance or on the thread,
14
+ # so it doesn't matter which instance of a row was saved, whether the row
15
+ # survives the transaction (a seat destroyed after its read), or which
16
+ # thread runs next. The rules are after_commit's:
17
+ #
18
+ # - with no transaction open, it runs at once;
19
+ # - in a transaction that joins its parent, and in a savepoint
20
+ # (`requires_new: true`) under a joinable parent, it moves up and runs
21
+ # at the outermost commit; a savepoint that rolls back drops only its
22
+ # own envelopes;
23
+ # - under a parent that is not joinable (and the wrapper Rails'
24
+ # transactional tests run each test in), it runs when the savepoint is
25
+ # released, when after_commit runs too.
26
+ #
27
+ # +key+: at most one envelope per key per transaction (the first one wins).
28
+ #
29
+ # The same shape as moderate's Moderate::DeferredNotices.
30
+ module AfterCommit
31
+ # What a transaction finalizes: Active Record calls `before_committed!`,
32
+ # then `committed!` or `rolledback!` on every record enrolled in it, and
33
+ # reads `trigger_transactional_callbacks?`, `destroyed?` and
34
+ # `_new_record_before_last_commit` when it chooses which instance of a
35
+ # row runs callbacks.
36
+ class Envelope
37
+ attr_accessor :_new_record_before_last_commit
38
+
39
+ def initialize(work)
40
+ @work = work
41
+ @finalized = false
42
+ end
43
+
44
+ def trigger_transactional_callbacks? = true
45
+ def has_transactional_callbacks? = true # rubocop:disable Naming/PredicatePrefix -- Active Record's name
46
+ def destroyed? = false
47
+ def before_committed!; end
48
+
49
+ # Once, when the transaction that owns it runs commit callbacks. (Active
50
+ # Record also calls it with should_run_callbacks: false when it is only
51
+ # cleaning up after an exception: nothing runs then.)
52
+ def committed!(should_run_callbacks: true)
53
+ return if @finalized
54
+
55
+ @finalized = true
56
+ @work.call if should_run_callbacks
57
+ end
58
+
59
+ def rolledback!(force_restore_state: false, should_run_callbacks: true)
60
+ @finalized = true
61
+ end
62
+ end
63
+
64
+ # The keys already enrolled, per transaction object (gone with it).
65
+ KEYS = ObjectSpace::WeakMap.new
66
+ private_constant :KEYS
67
+
68
+ class << self
69
+ def run(connection, key: nil, &work)
70
+ transaction = connection&.current_transaction
71
+ return work.call unless transaction&.open?
72
+
73
+ if key
74
+ keys = (KEYS[transaction] ||= Set.new)
75
+ return unless keys.add?(key)
76
+ end
77
+ connection.add_transaction_record(Envelope.new(work))
78
+ nil
79
+ end
80
+
81
+ # A model's connection, as of now (Rails 7.2 renamed it lease_connection).
82
+ def connection_for(model)
83
+ model.respond_to?(:lease_connection) ? model.lease_connection : model.connection
84
+ end
85
+ end
86
+ end
87
+ end
@@ -270,6 +270,12 @@ module Chats
270
270
  # and every participant except the sender gets a fresh unread badge.
271
271
  # Everyone else — the sender, and every seat on an edit or a delete —
272
272
  # gets the dock's list refresh alone.
273
+ #
274
+ # The badge moving is also a domain moment, `:unread_changed`, for
275
+ # every badge chats doesn't draw itself (a product gem's own count, a
276
+ # native app's). Emitted here because this is where it happens for
277
+ # every message, system ones included, which `:message_created`
278
+ # deliberately skips.
273
279
  def fan_out_inboxes(conversation, skip_badge_for: nil, badges: true, message: nil)
274
280
  conversation.participants.active.includes(:messager).each do |participant|
275
281
  messager = participant.messager
@@ -278,7 +284,9 @@ module Chats
278
284
  refresh_inbox_of(messager)
279
285
  if badges && messager != skip_badge_for
280
286
  update_badge_of(messager, conversation: conversation, message: message, participant: participant)
287
+ Chats.notify(:unread_changed, messager: messager, conversation: conversation)
281
288
  else
289
+
282
290
  update_dock_of(messager, conversation: conversation)
283
291
  end
284
292
  end
@@ -90,11 +90,24 @@ module Chats
90
90
  # read in a notification center the moment the thread is read, so a
91
91
  # bell badge doesn't keep advertising messages the user has already
92
92
  # seen). Same notify hook as :message_created; error-isolated.
93
+ # - `:unread_changed`, in the same case, once the read has COMMITTED
94
+ # (inside a host's transaction that rolls back, never): it promises
95
+ # subscribers the count they read is the one that stands.
93
96
  def read!(at: Time.current)
94
97
  return self if last_read_at && last_read_at >= at
95
98
 
96
99
  had_unread = unread?
97
- update!(last_read_at: at)
100
+ connection = Chats::AfterCommit.connection_for(self.class) # the one this read writes on
101
+ # The transaction owns the announcement (Chats::AfterCommit), the one the
102
+ # write is in: it runs at the real commit, never for a rollback, once per
103
+ # seat per transaction, whatever else happens to the seat meanwhile.
104
+ self.class.transaction do
105
+ update!(last_read_at: at)
106
+ if had_unread
107
+ seat = { messager: messager, conversation: conversation } # whatever becomes of the row
108
+ Chats::AfterCommit.run(connection, key: [:unread_consumed, id]) { Chats.notify(:unread_changed, **seat) }
109
+ end
110
+ end
98
111
  broadcast_read_state if Chats.config.read_receipts
99
112
  Chats::Broadcasts.refresh_inbox_of(messager)
100
113
  Chats::Broadcasts.update_badge_of(messager)
@@ -25,11 +25,24 @@ module Chats
25
25
  # :conversation_created conversation: a conversation just came into being
26
26
  # :participant_left participant: someone left a group
27
27
  # :conversation_read conversation:, participant: a read consumed unread content
28
+ # :unread_changed messager:, conversation: a messager's unread count there may
29
+ # have moved (any message, system ones
30
+ # included, or a read that consumed some).
31
+ # Always after the write commits (a
32
+ # message's fan-out; a read's, by its
33
+ # transaction: Chats::AfterCommit),
34
+ # never for one that rolls back. ONCE PER ELIGIBLE SEAT: a
35
+ # message in a 100-seat group calls it
36
+ # 99 times, synchronously, in that
37
+ # after_commit. Subscribers stay cheap
38
+ # (filter on the conversation first, no
39
+ # query) and enqueue, or coalesce.
28
40
  EVENTS = {
29
41
  message_created: :message,
30
42
  conversation_created: :conversation,
31
43
  participant_left: :participant,
32
- conversation_read: nil
44
+ conversation_read: nil,
45
+ unread_changed: nil
33
46
  }.freeze
34
47
 
35
48
  # The events the deprecated `config.notifier=` hook is subscribed to:
data/lib/chats/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chats
4
- VERSION = "0.6.2"
4
+ VERSION = "0.7.2"
5
5
  end
@@ -243,6 +243,7 @@ Chats.configure do |config|
243
243
  # app/views/chats/slots/_composer_actions.html.erb your own buttons above the composer
244
244
  # app/views/chats/slots/_locked_composer.html.erb the locked composer's body
245
245
  # app/views/chats/slots/_message_meta.html.erb after each bubble's timestamp
246
+ # app/views/chats/slots/_message_menu.html.erb your items in a message's menu (Report…)
246
247
  # app/views/chats/slots/_dock_panel_footer.html.erb beside "See all messages" in the dock
247
248
  #
248
249
  # `rails generate chats:views` is still there for wholesale restyling.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
@@ -138,8 +138,10 @@ files:
138
138
  - app/javascript/chats/dock_controller.js
139
139
  - app/javascript/chats/recovery.js
140
140
  - app/javascript/chats/refresh_inbox_controller.js
141
+ - app/javascript/chats/sheet.js
141
142
  - app/javascript/chats/split_controller.js
142
143
  - app/javascript/chats/thread_controller.js
144
+ - app/javascript/chats/tray_controller.js
143
145
  - app/javascript/chats/unread_badge_controller.js
144
146
  - app/views/chats/conversations/_conversation_pane.html.erb
145
147
  - app/views/chats/conversations/_conversation_row.html.erb
@@ -173,31 +175,21 @@ files:
173
175
  - app/views/chats/messages/locked.turbo_stream.erb
174
176
  - app/views/chats/shared/_unread_badge.html.erb
175
177
  - app/views/chats/shared/_verified_badge.html.erb
178
+ - app/views/chats/tray/_badge.html.erb
179
+ - app/views/chats/tray/_corner.html.erb
180
+ - app/views/chats/tray/_item.html.erb
181
+ - app/views/chats/tray/_tray.html.erb
176
182
  - config/importmap.rb
177
183
  - config/locales/en.yml
178
184
  - config/locales/es.yml
179
185
  - config/routes.rb
180
186
  - context7.json
181
- - docs/PRD.md
182
- - docs/campfire_review.md
183
- - docs/screenshots/composer-actions.png
184
- - docs/screenshots/dock-bar.png
185
- - docs/screenshots/dock-desktop.png
186
- - docs/screenshots/dock-phone-button.png
187
- - docs/screenshots/dock-phone-panel.png
188
- - docs/screenshots/dock-phone-window.png
189
- - docs/screenshots/inbox.png
190
- - docs/screenshots/split-inbox-empty.png
191
- - docs/screenshots/split-inbox-phone-list.png
192
- - docs/screenshots/split-inbox-phone-thread.png
193
- - docs/screenshots/split-inbox.png
194
- - docs/screenshots/system-note-action.png
195
- - docs/screenshots/thread-actions-phone.png
196
187
  - gemfiles/rails_7.1.gemfile
197
188
  - gemfiles/rails_7.2.gemfile
198
189
  - gemfiles/rails_8.1.gemfile
199
190
  - lib/chats.rb
200
191
  - lib/chats/action.rb
192
+ - lib/chats/after_commit.rb
201
193
  - lib/chats/broadcasts.rb
202
194
  - lib/chats/configuration.rb
203
195
  - lib/chats/engine.rb
data/docs/PRD.md DELETED
@@ -1,254 +0,0 @@
1
- # `chats` — PRD (draft v0.2)
2
-
3
- **A drop-in, real-time messaging engine for any Rails 8+ app.**
4
- Direct messages, group chats, reactions, attachments, read receipts — Hotwire-native, polymorphic, and wired into the rest of our gem ecosystem (`moderation`/`banbanban`, `goodmail`, future `notifications`/push) so a developer gets messaging **and** its Trust & Safety + notifications story by configuring once.
5
-
6
- > **Status: SHIPPED as v0.1.0 (2026-06-09).** This PRD is kept as the original design document; the README and the code are the source of truth now. What shipped vs. this draft:
7
- >
8
- > - **Built gem-first** (not CarHey-first): by build time the moderation system had already been extracted as the `moderate` gem, so the proven-shape argument for incubating in-app no longer applied. The gem was built standalone with its own dummy-app suite and integrated into CarHey in the same change (Gemfile `github: "rameerez/chats"`).
9
- > - **Namespace drift**: everywhere this doc says `Moderation::*` / `moderates_content` / `filter_policy`, the real extracted API is `Moderate::*`, `has_reportable_content`, `moderates`, `config.filter`, `Moderate.blocked_ids_for`. The interop shipped duck-typed (no hard dependency, §7's resolution), via `config.blocked_messager_ids` + plain contract methods on `Chats::Message`/`Chats::Conversation`.
10
- > - **No `Chats::MessageReceipt` table**: read state shipped as a per-participant horizon (`last_read_at`), which delivers unread counts + "Seen" with zero per-message writes (the Campfire model). Receipts can be added later without breaking API — see `Chats::Participant`'s doc.
11
- > - **Typing indicators** shipped as a Turbo Stream custom action over the existing `Turbo::StreamsChannel` (a debounced POST + broadcast), not a bespoke `Chats::ConversationChannel` — no Action Cable identification requirements on the host.
12
- > - **Noticed** remains uninstalled in CarHey; the interim notifier proc schedules a debounced goodmail job exactly as §8's caveat anticipated. The `c.notifier` seam is Noticed-ready.
13
- > - Open question §12 (cardinality) resolved: per-pair-**per-subject** when `about:` is passed, plain per-pair otherwise — the host picks. CarHey threads per listing.
14
-
15
- ---
16
-
17
- ## 1. Vision & positioning
18
-
19
- A gem you add to a Rails app to get **Instagram/X-DM-class** user-to-user messaging without building it again. The happy path is one generator + one `acts_as_messager` line + a mounted engine; the result is a working, real-time, polished inbox. Power users override views, policies, and adapters.
20
-
21
- It is **not** a chatbot/LLM framework, not a Slack-clone with workspaces, and not a support-ticketing tool (that is `support_desk`, a separate product gem built on this one — see README). It is peer-to-peer (and group) human messaging.
22
-
23
- **Why it exists:** every consumer app eventually needs DMs, and everyone rebuilds the same Conversation/Message/Participant/Receipt model, the same Action Cable + Turbo plumbing, and the same "report this message / block this user / filter this text" surface. We already built the moderation half for CarHey; `chats` is the messaging half, and the two snap together.
24
-
25
- ---
26
-
27
- ## 2. Design principles
28
-
29
- 1. **Hotwire-native, zero custom JS by default.** Turbo Streams for live message append/update/delete, Turbo Frames for lazy panes, Stimulus only for the few genuinely-interactive bits (composer autosize, typing throttle, scroll-to-bottom). No SPA, no build step (importmap-friendly).
30
- 2. **Mountable engine, `isolate_namespace Chats`.** All models/controllers/tables namespaced (`Chats::Conversation`, `chats_messages`). Host route names stay the host's choice.
31
- 3. **Polymorphic from day one.** A participant is not hard-typed to `User`. Any model that `acts_as_messager` can converse — `User`, `Organization`, a support `Agent`, a bot. This is the same inversion discipline the moderation gem uses (configurable class names + polymorphic targets).
32
- 4. **Good defaults, easy override.** Ships working controllers + views; every view is overridable by copying into the host; every policy is a config hook.
33
- 5. **Decoupled, adapter-driven ecosystem interop.** `chats` does not depend on CarHey. It depends on small, documented adapter interfaces for moderation, notifications, and auth — defaulting to no-ops so it runs standalone, and snapping onto `moderation`/`goodmail` when present.
34
- 6. **Compliance by composition.** Messages are UGC. Rather than re-implement report/block/filter, `chats` content plugs into the `moderation` gem, so adding `chats` to a compliant app keeps it compliant (DSA + Apple 1.2 + Google Play) instead of reopening those gaps.
35
- 7. **Delightful DX.** Consistent with the ecosystem: a `Chats.configure do |c| … end` block, `acts_as_*` macros, adapter objects, and an install generator — so `railsfast` + `goodmail` + `moderation` + `chats` feel like one coherent toolkit.
36
-
37
- ---
38
-
39
- ## 3. Architecture & data model
40
-
41
- ### Engine
42
- `Chats::Engine < ::Rails::Engine` with `isolate_namespace Chats`. Ships migrations, models, controllers, views, Stimulus controllers, Action Cable channels, and generators.
43
-
44
- ### Core models (namespaced, UUID PKs to match the ecosystem)
45
- - **`Chats::Conversation`** — `kind` (`direct` | `group`), `title` (groups), `last_message_at` (denormalized for inbox ordering), optional polymorphic `subject` (so a conversation can be *about* a host record — a ride, an order, a listing — which is how CarHey attaches a chat to a trip).
46
- - **`Chats::Participant`** — polymorphic `messager` (`messager_type`/`messager_id`), `conversation_id`, `role` (`member` | `admin` | `owner` for groups), `last_read_at`, `muted_at`, `left_at`. Unique on `[conversation_id, messager_type, messager_id]`.
47
- - **`Chats::Message`** — `conversation_id`, polymorphic `sender` (the messager), `body` (text), `reply_to_id` (self-ref for threads/quotes), `edited_at`, `deleted_at` (soft delete / "deleted for everyone"), `metadata` jsonb. Has many attachments (ActiveStorage) and reactions.
48
- - **`Chats::MessageReceipt`** — per-recipient delivery/read state (`message_id`, `participant_id`, `delivered_at`, `read_at`). Powers read receipts + unread counts. (For large groups, receipts are opt-in / aggregated — see §6.)
49
- - **`Chats::Reaction`** — `message_id`, polymorphic `reactor`, `emoji`. Unique on `[message_id, reactor, emoji]`.
50
- - Attachments via **ActiveStorage** `has_many_attached :files` on `Message` (configurable: disabled, images-only, any).
51
-
52
- ### Host integration: `acts_as_messager`
53
- ```ruby
54
- class User < ApplicationRecord
55
- acts_as_messager # injects has_many :chat_participations, :chat_messages, helpers
56
- end
57
- ```
58
- The macro is the single host contract on the actor side. A model can also `acts_as_chat_subject` if conversations attach to it (e.g. `Ride`).
59
-
60
- ### Tables (sketch)
61
- `chats_conversations`, `chats_participants`, `chats_messages`, `chats_message_receipts`, `chats_reactions`. All UUID, all polymorphic where noted, indexed for the two hot queries: **a messager's inbox** (`participants` by messager + `conversations.last_message_at`) and **a conversation's message page** (`messages` by `conversation_id, created_at`).
62
-
63
- ---
64
-
65
- ## 4. Real-time (Hotwire)
66
-
67
- - **Turbo Streams over Action Cable.** `Chats::Conversation` broadcasts `append`/`replace`/`remove` to a per-conversation stream; participants subscribe via `turbo_stream_from conversation`. New message → append to the thread + update each inbox row's preview/unread badge.
68
- - **`Chats::ConversationChannel`** (Action Cable) for presence + typing indicators (ephemeral, not persisted) and for authorizing the stream subscription (a non-participant must not subscribe).
69
- - **Stimulus** controllers (namespaced `chats--*`): `composer` (autosize, Enter-to-send, typing-throttle ping), `scroll` (stick-to-bottom, infinite-scroll older pages via Turbo Frame pagination), `receipts` (mark-read on viewport intersection).
70
- - **Read state**: when a participant views a conversation, mark messages read (debounced) → broadcast a lightweight receipt update so the sender sees "Seen".
71
- - Degrades gracefully: with cable down, it's still a working request/response inbox (Turbo Drive navigation); realtime is an enhancement, not a requirement.
72
-
73
- ---
74
-
75
- ## 5. Generators & onboarding
76
-
77
- - `rails g chats:install` → writes `config/initializers/chats.rb`, copies migrations, mounts the engine (`mount Chats::Engine => "/chats"`), adds `acts_as_messager` guidance.
78
- - `rails chats:install:migrations` → idempotent migration copy.
79
- - `rails g chats:views [scope]` → eject overridable views into the host.
80
- - `rails g chats:stimulus` / component generator → eject the Stimulus controllers / view components for deep customization.
81
-
82
- One-command setup; explicit initializer; no magic.
83
-
84
- ---
85
-
86
- ## 6. Configurable features
87
-
88
- `Chats.configure do |c| … end`:
89
- - `c.messager_class = "User"` (default) — the primary actor; polymorphic participants still allow others.
90
- - Feature flags (all default sensible): `c.groups = true`, `c.attachments = :images` (`false`/`:images`/`:any`), `c.reactions = true`, `c.read_receipts = true`, `c.typing_indicators = true`, `c.editing = true`, `c.deletion = :soft`, `c.search = true`, `c.threads = false`.
91
- - `c.messages_per_page = 30`, `c.max_message_length`, `c.max_group_size`.
92
- - `c.read_receipts_max_group_size` — disable per-recipient receipts above N (perf).
93
- - **Policies** (the authorization seam): `c.can_message = ->(from:, to:) { … }` (default: true unless blocked — see moderation), `c.can_create_group`, `c.can_join`. Defaults are permissive but trivially overridable; CarHey will scope DMs to a confirmed ride relationship initially.
94
- - **Adapters** (the ecosystem seam — §7/§8): `c.moderation`, `c.notifiers`, `c.authorizer`, `c.current_messager`.
95
-
96
- ---
97
-
98
- ## 7. Moderation interop (the critical seam)
99
-
100
- `chats` content is UGC; it must be reportable, blockable, and filterable. Instead of re-implementing T&S, `chats` plugs into the **`moderation`** gem (the system CarHey is extracting from PR #28). The interop is via small, documented contracts so `chats` works standalone (no-op adapter) and lights up when `moderation` is present.
101
-
102
- ### 7.1 Reportable messages & conversations
103
- `Chats::Message` (and `Chats::Conversation`) `include Moderation::Reportable` and implement the concern contract already proven in CarHey:
104
- ```ruby
105
- class Chats::Message < ApplicationRecord
106
- include Moderation::Reportable
107
- reportable_fields :body
108
- def reported_owner = sender
109
- def moderation_label = "Message #{id}"
110
- def moderation_snapshot_text(field) = body if field.to_s == "body"
111
- def remove_reported_field!(field) = (update!(body: nil, deleted_at: Time.current); true) if field.to_s == "body"
112
- def report_visible_to?(viewer, field:) = participant?(viewer) # only people in the convo can report it
113
- # moderation_subject_url / return_path / admin_path take a routes object (already the concern's shape)
114
- end
115
- ```
116
- The host registers these in `Moderation.config.reportable_class_names`. **No CarHey coupling** — this is exactly the polymorphic adapter the moderation concern was designed for. A "Denunciar mensaje" affordance reuses moderation's `report_link` helper.
117
-
118
- ### 7.2 Block enforcement (blocked users can't DM)
119
- `chats`' default `can_message` policy and inbox/visibility queries consult a **block adapter** that defaults to `Moderation::Block.user_ids_related_to(user)` (the bidirectional SSOT query). A blocked pair: cannot start a new conversation, cannot send into an existing one, and don't surface to each other. Wire-once: `c.moderation.blocked_ids_for = ->(user) { Moderation::Block.user_ids_related_to(user) }`. When `moderation` isn't installed, the default is "nobody blocked".
120
-
121
- ### 7.3 Content filtering with configurable modes
122
- Filtering is configurable **per class/field** via a registry on the moderation gem (keyed by `"Class#field"`, ancestor-aware), with three modes:
123
- - **`:off`** — no check.
124
- - **`:block`** — reject at write time. Stays an ActiveModel **validator** (it legitimately stops the save).
125
- - **`:flag`** — the write **succeeds**, and a pending review item is created **out-of-band**.
126
-
127
- Wired once in `config/initializers/moderation.rb`:
128
- ```ruby
129
- Moderation.configure do |c|
130
- c.filter_policy "Chats::Message", :body, mode: :flag
131
- c.filter_policy "Chats::Message", :files, mode: :flag, adapter: Chats::AttachmentReviewAdapter
132
- end
133
- ```
134
-
135
- Two design rules are now proven in CarHey:
136
- 1. **`:flag` does not live in a validator.** Validators remain side-effect-free. `:flag` runs from `Moderation::ContentFilterable` after commit, so system-generated review rows survive only after the host row exists and can be safely consumed by human/admin or ML workers.
137
- 2. **The flag is a separate `Moderation::Flag` table, not `Moderation::Report`.** `Report` models a human notice/report with contact fields, message, good-faith confirmation, DSA taxonomy, decision, and appeal window. A system flag has no notifier. Current CarHey shape: polymorphic `flaggable`, optional `owner`, `field`, `source` (`text_filter`, `image_filter`, `external_classifier`, `manual`), `mode` (`flag`, `block`), `status` (`pending`, `actioned`, `dismissed`), `excerpt`, `categories`, `scores`, and `context`. A shared `pending` scope is the SSOT both a Madmin queue and future ML consumers read.
138
-
139
- **Adapter interface** (one method, score-carrying so a wordlist and an AI classifier are interchangeable):
140
- ```ruby
141
- adapter.classify(value) # => { allowed:, categories:, scores:, source:, metadata: }
142
- ```
143
- The moderation core wraps adapter hashes in `Moderation::FilterResult`. Built-in wordlist behavior is CarHey's `TextFilter` (NFKD normalization, leetspeak folding, spacing/accent resistance, Spanish/English YAML blocklist), score `1.0` per hit. Optional `:openai`/`:ruby_llm` adapters can return real `0..1` scores, and should run from a background job in `:flag` mode for expensive media/ML checks. Report intake only re-runs a block-mode adapter synchronously when the adapter explicitly exposes `synchronous? == true`; external/network adapters should leave that false and rely on `Moderation::Flag` evidence instead. The existing `moderate` gem remains a possible upstream home for this adapter interface, but not a hard dependency: today it is English-oriented and too narrow for CarHey/Spanish compliance needs.
144
-
145
- `chats` consumes all of this with the real **two-part** shape: on the model, `include Moderation::ContentFilterable` **+ `moderates_content :body`** (declares the scanned fields — drives the `validate` for `:block` and the `after_commit` for `:flag`); in the initializer, one `c.filter_policy "Chats::Message", :body, mode: :flag` line (sets the mode). A `filter_policy` line **without** `moderates_content` silently does nothing. Flagging is `Moderation::Flag.flag!` called from `ContentFilterable` (there is **no** `Moderation.flag` facade, and the standalone `ObjectionableContentValidator` is dead code — the concern owns `:block` too). For `:files` (attachments), override `moderation_field_value` / `moderation_field_changed_for_commit?` since the default `public_send(:files)` returns an ActiveStorage proxy, not classifiable content (mirror CarHey's host-supplied `ImageReviewAdapter`). The filter-mode + `Flag` machinery is a **moderation-gem change to land first** (independently useful for CarHey avatars today).
146
-
147
- ### 7.4 Compliance inheritance
148
- Because messages flow through moderation, adding `chats` to a DSA/Apple/Google-compliant app **keeps it compliant only if the host enables the required policies**:
149
- - `Chats::Message` and `Chats::Conversation` are registered reportable classes.
150
- - Message body and attachment policies are configured (`:block`, `:flag`, or explicit `:off`).
151
- - Blocking is enforced before conversation creation and before every send.
152
- - Every message/conversation UI exposes report and block affordances.
153
- - Admins can see `Moderation::Report`, `Moderation::Flag`, and `Moderation::Appeal` queues.
154
- - Report decisions are delivered via moderation: the **DSA legal email** (receipt / decision / statement-of-reasons) goes through moderate's direct, synchronous goodmail seam (its boolean return gates the `*_notified_at` DSA timestamps — Noticed's fire-and-forget can't honor that), while **affected-user surfaces** (in-app feed + push) are layered via a host Noticed notifier. This is moderation's responsibility, not "the chats bus."
155
-
156
- This is a launch gate for the CarHey chats PR, not optional polish. A chat surface is high-velocity UGC; shipping it without these hooks would regress App Store Guideline 1.2, Google Play UGC, and DSA notice-and-action.
157
-
158
- ---
159
-
160
- ## 8. Notifications: integrate with Noticed (the host's adopted orchestrator)
161
-
162
- **The host has chosen [Noticed](https://github.com/excid3/noticed) v3 as the notification orchestrator + in-app feed, and `action_push_native` for push** (see CarHey's `docs/notifications_architecture_prd.md`). `chats` does **not** build its own fan-out bus — Noticed already owns multi-subscriber fan-out, the per-recipient in-app feed (`Noticed::Notification` records), per-user preferences (`if/unless`), and the channel adapters (email/action_cable/action_push_native/custom). A second bus inside `chats` would double-wire goodmail and strand chats events away from push/feed/telegram. (Do **not** route chats through the `moderate` gem's `Moderation.notify` PORO either — that bus exists only to gate moderate's synchronous DSA legal email; it is not the cross-channel orchestrator.)
163
-
164
- The shape:
165
- - **`chats` is a Noticed event *source*.** On notification-worthy domain moments, `chats` fires a host **Noticed Notifier** through a single no-op-default `c.notifier` adapter proc. In CarHey that proc is `->(event, **p) { NewMessageNotifier.with(message: p[:message]).deliver }`. The gem takes **no hard dependency on Noticed** (the default proc is a no-op, so `chats` runs standalone).
166
- - **Recipients live in the host Notifier**, not in `chats`. `chats` passes the domain object (the message/conversation); the Notifier's `recipients -> { params[:message].conversation.participants.excluding(params[:message].sender) }` computes who gets it.
167
- - **The 5 channels are host-owned Noticed delivery methods**, wired once: in-app feed (Noticed core + a `TurboStream` delivery method for the live bell), the **chats DM surface** (a custom `DeliveryMethods::Chats`), **email via goodmail** (Noticed's built-in `:email` pointing at a normal `ApplicationMailer` that uses `goodmail_mail` — **goodmail needs zero changes**, and it is **not** `Goodmail::Base`), **push via `action_push_native`**, and **admin Telegram via `telegrama`** as a `bulk_deliver_by`.
168
- - **The in-app feed is Noticed's, not chats'.** `chats` owns only the chat/DM surface (channel 2). The bell/feed is `current_user.notifications` (`Noticed::Notification`).
169
- - **Debounced email is a Noticed config**, not a chats-owned digest job: `config.wait = 10.minutes` + `config.unless = -> { read? }` gives the classic "email only if still unread."
170
-
171
- > **Status caveat:** Noticed + `action_push_native` are **not yet installed** in CarHey. Until they are, the interim CarHey wiring may call goodmail directly. The *target* is the Noticed integration above; `chats`'s hooks are designed so that milestone wires Noticed **once** with no gem changes. Two push gotchas to honor when it lands (from the action_push_native source): the `:action_push_native` delivery method calls `with_apple`/`with_google`/`with_data` unconditionally — **all three must be set** (at least `-> { {} }`); and `before_enqueue` is a real Noticed v3 callback that may `throw(:abort)`, while `config.if`/`config.unless` remain the clearer preference gates.
172
-
173
- ### 8.1 Chats domain moments → Noticed notifiers (not a private bus)
174
-
175
- These are the moments at which `chats` fires a host Notifier — they are **not** events on a chats-owned bus:
176
-
177
- | Domain moment | Host Notifier | Channels |
178
- |---|---|---|
179
- | message created | `NewMessageNotifier` | feed + chats DM + push + debounced email |
180
- | @mention | `MentionNotifier` (optional) | feed + push + email |
181
- | added to conversation | `ParticipantAddedNotifier` (optional) | feed + push |
182
-
183
- Non-notification domain changes (edited / deleted / reacted) need **no** notification framework — handle locally, or emit `ActiveSupport::Notifications` if instrumentation is wanted. **Do not emit a `message_flagged` event** — flagging is owned by `Moderation::Flag` / `ContentFilterable`; re-emitting it would double-count. Each Notifier's payload carries the domain object + actor + a stable idempotency key (Noticed `params` + dedup).
184
-
185
- ### 8.2 `chats` is both a Noticed *source* and a Noticed *target*
186
-
187
- These are different directions and both are true (this resolves the ambiguity flagged in the host PRD §11):
188
- - **Source:** a user message fires `NewMessageNotifier` (above) → participants get bell + push + email.
189
- - **Target:** other notifiers post a *system* message **into** a conversation via the custom `DeliveryMethods::Chats`, calling a stable chats API — `Chats::Conversation#post_system_message!(body:)` (a `Chats::SystemSender` participant), e.g. "Your ride was cancelled" dropped into the ride's chat. The host delivery method must match chats' real signature — messages have a **conversation + participants**, not a per-message `recipient:`, so the illustrative `Chats::Message.create!(recipient:)` in the host PRD §5.5 is wrong; chats exposes `post_system_message!` instead.
190
-
191
- ---
192
-
193
- ## 9. Security, privacy, performance
194
-
195
- - **Authorization everywhere**: channel subscriptions, message reads, and mutations all gate on participation + the `authorizer` adapter. A non-participant can't read or stream a conversation.
196
- - **Privacy**: soft-delete semantics ("delete for me" vs "delete for everyone"); optional message encryption-at-rest (ActiveRecord Encryption) behind a flag; PII-aware (don't leak presence to blocked users).
197
- - **Abuse/rate limits**: per-sender send rate limit (Rails 8 `rate_limit`); attachment type/size limits; optional attachment scanning hook.
198
- - **Compliance rate limits**: reporting/appeal forms use anti-abuse checks owned by moderation; message sends have separate high-throughput per-sender limits so attackers cannot create unlimited UGC before moderation catches up.
199
- - **Scale**: denormalized `last_message_at`, receipt opt-out for large groups, cursor pagination, `find_each`-friendly broadcasts, counter-cached unread.
200
-
201
- ---
202
-
203
- ## 10. Cross-gem DX conventions (the ecosystem)
204
-
205
- To make this "the best Ruby gem ecosystem," `chats` follows and reinforces shared conventions with `railsfast`, `goodmail`, `moderation`, `organizations`, `pricing_plans`:
206
- - **One `X.configure do |c| … end` block per gem**, required keys validated at boot.
207
- - **`acts_as_*` host macros** for model integration (`acts_as_messager`, `acts_as_reportable`/`Moderation::Reportable`, etc.).
208
- - **Adapter objects + procs** for cross-gem seams (notifier, moderation, audit), defaulting to no-ops so each gem runs standalone.
209
- - **Configurable class-name strings + polymorphic targets** (never hard constants) so no gem depends on a host's `User`.
210
- - **Install generators** writing a templated initializer + idempotent migrations + engine mount.
211
- - **Goodmail for all transactional mail**; **moderation for all UGC T&S**; **a shared event/notifier contract** so notifications fan out uniformly.
212
-
213
- Target experience: `bundle add chats moderation goodmail`, run three installers, add `acts_as_messager` + `include Moderation::Reportable`, set a couple of adapter procs in initializers — and you have real-time DMs with reporting, blocking, filtering, email + push notifications, and DSA/store compliance, with every view overridable.
214
-
215
- ---
216
-
217
- ## 11. Build plan
218
-
219
- **Phase 0 — in CarHey (validate the shape against a real app):**
220
- 1. Replace the interim coordination model (the `Ride::JoinRequest#message` free-text + the `/messages` "Próximamente" placeholder) with real `Chats` conversations attached to a ride (`acts_as_chat_subject` on `Ride`).
221
- 2. `Chats::Message include Moderation::Reportable`; register it in `Moderation.config.reportable_class_names`; reuse `report_link` + the block enforcement.
222
- 3. Wire `Chats.config.notifier` → goodmail (`ChatsGoodmailer`) and `Chats.config.moderation` → the moderation adapters.
223
- 4. Scope `can_message` to confirmed ride relationships (CarHey policy); prove the polymorphic/adapter seams under a real workload + tests.
224
- 5. Launch gate: no chat PR merges unless report/block/filter/flag/appeal paths are green for `Chats::Message`, attachments, and conversations.
225
-
226
- **Phase 1 — extract to the gem:** `bundle gem chats` (engine), move the namespaced models/controllers/views/channels/generators, add `messager_class` string + `constantize`, ship default `en` locale + overridable views, write the install generator, and replace CarHey's direct usage with the gem.
227
-
228
- **Phase 2 — ecosystem polish:** extract the shared event dispatcher if moderation + chats both prove the same API; ship optional `goodmail`, `moderation`, `noticed`, and push adapters; document one canonical RailsFast setup.
229
-
230
- ---
231
-
232
- ## 11.1 Acceptance criteria for CarHey v1
233
-
234
- - A driver/passenger with an accepted ride relationship can open a conversation from the ride screen.
235
- - New messages appear in real time through Turbo Streams and still work without WebSockets.
236
- - A participant can report a message, block the sender, and stop receiving future messages from that sender.
237
- - `Chats::Message#body` goes through moderation filter policy; attachments create `Moderation::Flag` rows in `:flag` mode.
238
- - Madmin shows reports, flags, and appeals for chat content without CarHey-specific case statements.
239
- - Goodmail sends a debounced unread-message digest through the shared notifier array.
240
- - Full CarHey test suite covers send/read/receipt/report/block/filter/appeal happy paths and authz negatives.
241
-
242
- ---
243
-
244
- ## 12. Open questions
245
- - Conversation ↔ subject cardinality (one chat per ride? per pair-per-ride?).
246
- - Group membership model for very large groups (receipts, fan-out) — start capped.
247
- - E2E encryption: out of scope v1 (at-rest only); revisit.
248
- - Search backend: start with Postgres `ILIKE`/trigram; pluggable adapter for later.
249
- - Exact dependency direction: does `chats` depend on `moderation` directly, or only on a `moderation`-shaped adapter interface? → **adapter interface** (keeps `chats` usable without `moderation`), with a first-class `moderation` adapter shipped.
250
-
251
- ---
252
-
253
- ## 13. Non-goals (v1)
254
- Bots/LLM agents, workspaces/tenancy, voice/video, message scheduling, public channels/communities, federation. Deferred, not designed against.