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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +147 -0
- data/README.md +90 -28
- data/app/assets/stylesheets/chats.css +225 -15
- data/app/helpers/chats/engine_helper.rb +101 -7
- data/app/javascript/chats/dock_controller.js +54 -33
- data/app/javascript/chats/sheet.js +69 -0
- data/app/javascript/chats/thread_controller.js +199 -5
- data/app/javascript/chats/tray_controller.js +506 -0
- data/app/views/chats/conversations/_thread.html.erb +4 -0
- data/app/views/chats/dock/_dock.html.erb +9 -1
- data/app/views/chats/messages/_message.html.erb +30 -3
- data/app/views/chats/tray/_badge.html.erb +12 -0
- data/app/views/chats/tray/_corner.html.erb +16 -0
- data/app/views/chats/tray/_item.html.erb +44 -0
- data/app/views/chats/tray/_tray.html.erb +27 -0
- data/config/importmap.rb +4 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/lib/chats/after_commit.rb +87 -0
- data/lib/chats/broadcasts.rb +8 -0
- data/lib/chats/models/participant.rb +14 -1
- data/lib/chats/subscribers.rb +14 -1
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- metadata +8 -16
- data/docs/PRD.md +0 -254
- data/docs/campfire_review.md +0 -46
- data/docs/screenshots/composer-actions.png +0 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/docs/screenshots/inbox.png +0 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/docs/screenshots/system-note-action.png +0 -0
- 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"
|
data/config/locales/en.yml
CHANGED
data/config/locales/es.yml
CHANGED
|
@@ -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
|
data/lib/chats/broadcasts.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/chats/subscribers.rb
CHANGED
|
@@ -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
|
@@ -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.
|
|
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.
|