chats 0.1.1 → 0.2.0
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 +166 -0
- data/README.md +163 -28
- data/app/assets/stylesheets/chats.css +44 -0
- data/app/controllers/chats/conversations_controller.rb +43 -43
- data/app/controllers/chats/messages_controller.rb +34 -0
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +94 -0
- data/app/javascript/chats/refresh_inbox_controller.js +86 -0
- data/app/views/chats/conversations/_group.html.erb +38 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
- data/app/views/chats/conversations/index.html.erb +39 -9
- data/app/views/chats/conversations/show.html.erb +44 -3
- data/app/views/chats/messages/_composer.html.erb +4 -0
- data/app/views/chats/messages/_message.html.erb +29 -8
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +11 -0
- data/config/locales/es.yml +11 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/gemfiles/rails_7.1.gemfile +1 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/lib/chats/configuration.rb +63 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +303 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/macros.rb +24 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +81 -2
- data/lib/chats/models/conversation.rb +59 -5
- data/lib/chats/models/message.rb +67 -3
- data/lib/chats/models/participant.rb +59 -0
- data/lib/chats/models/reaction.rb +5 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +114 -15
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
- data/lib/generators/chats/templates/initializer.rb +95 -14
- data/lib/generators/chats/upgrade_generator.rb +48 -0
- metadata +12 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# The event bus behind `Chats.on`. One registry per event, many
|
|
5
|
+
# subscribers per event, each one isolated:
|
|
6
|
+
#
|
|
7
|
+
# Chats.on(:message_created) { |message| NewMessageNotifier.deliver(message) }
|
|
8
|
+
# Chats.on(:conversation_read) { |conversation:, participant:| Bell.mark_read(participant) }
|
|
9
|
+
#
|
|
10
|
+
# Two rules make this safe to use from a gem (the shape the `wallets` gem's
|
|
11
|
+
# CallbackDispatcher established):
|
|
12
|
+
#
|
|
13
|
+
# 1. A raising subscriber is REPORTED, never swallowed and never fatal —
|
|
14
|
+
# `Rails.error.report(e, handled: true, context: { event: })` — so the
|
|
15
|
+
# next subscriber still runs and the message still gets delivered.
|
|
16
|
+
# 2. Registration is reload-safe: pass `key:` and re-registering the same
|
|
17
|
+
# key REPLACES the subscriber in place, so a `to_prepare` block in a
|
|
18
|
+
# host app doesn't stack duplicates on every code reload.
|
|
19
|
+
module Subscribers
|
|
20
|
+
# Every event the gem emits, mapped to the payload key it yields
|
|
21
|
+
# POSITIONALLY to subscribers (nil = the whole payload is yielded as
|
|
22
|
+
# keywords). Registering for anything else raises at boot.
|
|
23
|
+
#
|
|
24
|
+
# :message_created message: every persisted human message
|
|
25
|
+
# :conversation_created conversation: a conversation just came into being
|
|
26
|
+
# :participant_left participant: someone left a group
|
|
27
|
+
# :conversation_read conversation:, participant: a read consumed unread content
|
|
28
|
+
EVENTS = {
|
|
29
|
+
message_created: :message,
|
|
30
|
+
conversation_created: :conversation,
|
|
31
|
+
participant_left: :participant,
|
|
32
|
+
conversation_read: nil
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# The events the deprecated `config.notifier=` hook is subscribed to:
|
|
36
|
+
# exactly the two that existed in 0.1.1, and no more. A 0.1.x notifier is
|
|
37
|
+
# commonly written `->(event, message:, **)`, which would raise
|
|
38
|
+
# ArgumentError on an event that carries no `message:` — so the new
|
|
39
|
+
# events are `Chats.on` only, and an old hook keeps behaving exactly as
|
|
40
|
+
# it did.
|
|
41
|
+
LEGACY_NOTIFIER_EVENTS = %i[message_created conversation_read].freeze
|
|
42
|
+
|
|
43
|
+
# Reserved key for the subscriber `config.notifier=` registers, so
|
|
44
|
+
# re-assigning the deprecated hook replaces rather than stacks.
|
|
45
|
+
NOTIFIER_KEY = :chats_config_notifier
|
|
46
|
+
|
|
47
|
+
# One registered callable. +style+ is how it gets invoked:
|
|
48
|
+
# :payload the modern `Chats.on` shape (positional record, or keywords)
|
|
49
|
+
# :event the deprecated `config.notifier` shape — `(event, **payload)`
|
|
50
|
+
class Subscriber
|
|
51
|
+
attr_reader :key, :callable, :style
|
|
52
|
+
|
|
53
|
+
def initialize(callable, key: nil, style: :payload)
|
|
54
|
+
@callable = callable
|
|
55
|
+
@key = key
|
|
56
|
+
@style = style
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Invoke the subscriber for +event+ with the emitted +payload+ hash.
|
|
60
|
+
def call(event, payload)
|
|
61
|
+
if style == :event
|
|
62
|
+
callable.call(event, **payload)
|
|
63
|
+
elsif (positional = EVENTS[event])
|
|
64
|
+
callable.call(payload[positional])
|
|
65
|
+
else
|
|
66
|
+
callable.call(**payload)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
# Register +block+ for +event+. Returns the Subscriber.
|
|
73
|
+
def on(event, key: nil, style: :payload, &block)
|
|
74
|
+
event = validate_event!(event)
|
|
75
|
+
raise ConfigurationError, "Chats.on(#{event.inspect}) requires a block" if block.nil?
|
|
76
|
+
|
|
77
|
+
subscriber = Subscriber.new(block, key: key, style: style)
|
|
78
|
+
replace_or_append(registry[event], subscriber)
|
|
79
|
+
subscriber
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Every subscriber registered for +event+, in registration order.
|
|
83
|
+
def for(event)
|
|
84
|
+
registry[validate_event!(event)].dup
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Run every subscriber of +event+, each isolated from the others.
|
|
88
|
+
# Returns the number of subscribers invoked.
|
|
89
|
+
#
|
|
90
|
+
# Emitting is never allowed to raise: it runs inside `after_commit`
|
|
91
|
+
# hooks, where an exception would punish a write that already
|
|
92
|
+
# succeeded. An unknown event here is a bug in the CALLER, so it is
|
|
93
|
+
# logged and skipped; `Chats.on` is where a bad event name fails loudly,
|
|
94
|
+
# at boot, where someone can fix it.
|
|
95
|
+
def emit(event, **payload)
|
|
96
|
+
event = event.to_sym
|
|
97
|
+
unless EVENTS.key?(event)
|
|
98
|
+
Chats.logger&.error("[chats] ignoring unknown event #{event.inspect}")
|
|
99
|
+
return 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
subscribers = registry[event]
|
|
103
|
+
|
|
104
|
+
subscribers.each do |subscriber|
|
|
105
|
+
subscriber.call(event, payload)
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
report(e, event)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
subscribers.size
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Drop every registration (used by `Chats.reset!` and by hosts that
|
|
114
|
+
# re-register from a `to_prepare` block).
|
|
115
|
+
def reset!
|
|
116
|
+
@registry = nil
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def registry
|
|
123
|
+
@registry ||= EVENTS.keys.index_with { [] }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_event!(event)
|
|
127
|
+
event = event.to_sym
|
|
128
|
+
return event if EVENTS.key?(event)
|
|
129
|
+
|
|
130
|
+
raise ConfigurationError,
|
|
131
|
+
"unknown chats event #{event.inspect} — valid events are #{EVENTS.keys.map(&:inspect).join(", ")}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Keyed subscribers replace in place (same position, so ordering is
|
|
135
|
+
# stable across code reloads); keyless ones always append.
|
|
136
|
+
def replace_or_append(list, subscriber)
|
|
137
|
+
index = subscriber.key && list.index { |existing| existing.key == subscriber.key }
|
|
138
|
+
if index
|
|
139
|
+
list[index] = subscriber
|
|
140
|
+
else
|
|
141
|
+
list << subscriber
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A failing subscriber must be VISIBLE (not just logged): the host's
|
|
146
|
+
# error reporter is the one surface that pages someone.
|
|
147
|
+
def report(error, event)
|
|
148
|
+
if defined?(::Rails) && ::Rails.respond_to?(:error) && ::Rails.error
|
|
149
|
+
::Rails.error.report(error, handled: true, context: { event: event })
|
|
150
|
+
else
|
|
151
|
+
Chats.logger&.error("[chats] subscriber raised on #{event}: #{error.class}: #{error.message}")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/chats/version.rb
CHANGED
data/lib/chats.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "global_id"
|
|
|
6
6
|
require_relative "chats/version"
|
|
7
7
|
require_relative "chats/errors"
|
|
8
8
|
require_relative "chats/configuration"
|
|
9
|
+
require_relative "chats/subscribers"
|
|
9
10
|
require_relative "chats/macros"
|
|
10
11
|
|
|
11
12
|
require_relative "chats/engine" if defined?(::Rails::Engine)
|
|
@@ -24,6 +25,8 @@ require_relative "chats/engine" if defined?(::Rails::Engine)
|
|
|
24
25
|
# user.chat_with(other) # find-or-create a direct conversation
|
|
25
26
|
# user.message!(other, "hello!") # ...and say something in one line
|
|
26
27
|
#
|
|
28
|
+
# Chats.on(:message_created) { |m| } # subscribe to the domain moments
|
|
29
|
+
#
|
|
27
30
|
# Everything else (controllers, views, broadcasts) ships with the engine and
|
|
28
31
|
# is overridable the Devise way (`rails g chats:views`).
|
|
29
32
|
module Chats
|
|
@@ -48,9 +51,16 @@ module Chats
|
|
|
48
51
|
@config = Configuration.new
|
|
49
52
|
@messager_classes = nil
|
|
50
53
|
@subject_classes = nil
|
|
54
|
+
reset_subscribers!
|
|
51
55
|
self
|
|
52
56
|
end
|
|
53
57
|
|
|
58
|
+
# The gem's own deprecator (registered with `Rails.application.deprecators`
|
|
59
|
+
# by the engine, so `config.active_support.deprecation` governs it).
|
|
60
|
+
def deprecator
|
|
61
|
+
@deprecator ||= ActiveSupport::Deprecation.new("1.0", "chats")
|
|
62
|
+
end
|
|
63
|
+
|
|
54
64
|
# --- Registries -----------------------------------------------------------
|
|
55
65
|
#
|
|
56
66
|
# `acts_as_messager` / `acts_as_chat_subject` self-register the calling
|
|
@@ -123,23 +133,34 @@ module Chats
|
|
|
123
133
|
config.can_message.call(sender, recipient)
|
|
124
134
|
end
|
|
125
135
|
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
#
|
|
129
|
-
#
|
|
136
|
+
# --- Events ---------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
# Subscribe to a domain moment. Many subscribers per event; each one runs
|
|
139
|
+
# isolated, so a raising subscriber is reported and the others still run.
|
|
140
|
+
#
|
|
141
|
+
# Chats.on(:message_created) { |message| }
|
|
142
|
+
# Chats.on(:conversation_created) { |conversation| }
|
|
143
|
+
# Chats.on(:participant_left) { |participant| }
|
|
144
|
+
# Chats.on(:conversation_read) { |conversation:, participant:| }
|
|
130
145
|
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
|
|
146
|
+
# Pass `key:` from reloadable code (a `to_prepare` block): re-registering
|
|
147
|
+
# the same key REPLACES the previous subscriber instead of stacking a
|
|
148
|
+
# duplicate on every code reload.
|
|
149
|
+
def on(event, key: nil, &block)
|
|
150
|
+
Subscribers.on(event, key: key, &block)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Drop every `Chats.on` registration (also called by `reset!`).
|
|
154
|
+
def reset_subscribers!
|
|
155
|
+
Subscribers.reset!
|
|
156
|
+
self
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Fire a domain event at every subscriber (see Chats.on). Error-isolated:
|
|
160
|
+
# a broken subscriber must never break message delivery itself — the
|
|
161
|
+
# message is already committed; notifications are best-effort fan-out.
|
|
135
162
|
def notify(event, **payload)
|
|
136
|
-
|
|
137
|
-
rescue StandardError => e
|
|
138
|
-
# A broken notifier must never break message delivery itself — the
|
|
139
|
-
# message is already committed; notifications are best-effort fan-out.
|
|
140
|
-
# Same error-isolation philosophy as pricing_plans' lifecycle callbacks.
|
|
141
|
-
logger&.error("[chats] notifier raised on #{event}: #{e.class}: #{e.message}")
|
|
142
|
-
nil
|
|
163
|
+
Subscribers.emit(event, **payload)
|
|
143
164
|
end
|
|
144
165
|
|
|
145
166
|
# --- Display helpers (used by the bundled views) --------------------------
|
|
@@ -156,6 +177,72 @@ module Chats
|
|
|
156
177
|
config.messager_avatar.call(messager)
|
|
157
178
|
end
|
|
158
179
|
|
|
180
|
+
# Where a messager's profile lives, per `config.messager_url` (nil by
|
|
181
|
+
# default — the bundled views then render plain text, never a dead link).
|
|
182
|
+
def messager_url_for(messager)
|
|
183
|
+
return nil if messager.nil?
|
|
184
|
+
|
|
185
|
+
config.messager_url.call(messager).presence
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The signature line under a signed message ("— Lucía G."), per
|
|
189
|
+
# `config.message_signature` when the host sets one. Nil for messages
|
|
190
|
+
# that aren't signed (see Chats::Message#signed?).
|
|
191
|
+
def message_signature_for(message)
|
|
192
|
+
return nil if message.nil? || !message.signed?
|
|
193
|
+
|
|
194
|
+
if config.message_signature
|
|
195
|
+
config.message_signature.call(message).presence
|
|
196
|
+
else
|
|
197
|
+
I18n.t("chats.message.signature", name: display_name_for(message.author))
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# --- Messager options (see acts_as_messager) --------------------------------
|
|
202
|
+
|
|
203
|
+
# Whether +messager+ can be notified at all. False for headless messagers
|
|
204
|
+
# declared with `acts_as_messager notifications: false` (a support desk, a
|
|
205
|
+
# bot): hosts stop branching on class in every notifier.
|
|
206
|
+
def notifications_for?(messager)
|
|
207
|
+
messager_option(messager, :chat_notifications?)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Whether block/report affordances make sense against +messager+.
|
|
211
|
+
# False for `acts_as_messager blockable: false`.
|
|
212
|
+
def blockable?(messager)
|
|
213
|
+
messager_option(messager, :chat_blockable?)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Whether +messager+'s direct conversations stack into one inbox row
|
|
217
|
+
# (`acts_as_messager inbox: :grouped`).
|
|
218
|
+
def grouped_inbox?(messager)
|
|
219
|
+
messager_option(messager, :chat_grouped_inbox?, default: false)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The polymorphic type names of every registered messager class that
|
|
223
|
+
# stacks (`inbox: :grouped`). Empty in an ordinary app — which is what
|
|
224
|
+
# keeps the inbox query there byte-identical to 0.1.x. Used as a SQL
|
|
225
|
+
# PREFILTER only; whether a given counterpart actually stacks is still
|
|
226
|
+
# decided per-record by `grouped_inbox?` (STI subclasses share a
|
|
227
|
+
# polymorphic_name with siblings that may not be grouped).
|
|
228
|
+
def grouped_messager_types
|
|
229
|
+
messager_class_names.filter_map do |name|
|
|
230
|
+
klass = name.safe_constantize
|
|
231
|
+
next unless klass.respond_to?(:chat_grouped_inbox?) && klass.chat_grouped_inbox?
|
|
232
|
+
|
|
233
|
+
klass.polymorphic_name
|
|
234
|
+
end.uniq
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The signed GlobalID that scopes the inbox to conversations with
|
|
238
|
+
# +messager+ (`GET /conversations?with=…`). Purpose-scoped and
|
|
239
|
+
# non-expiring: inbox rows live on long-lived pages.
|
|
240
|
+
def inbox_with_sgid(messager)
|
|
241
|
+
return nil if messager.nil?
|
|
242
|
+
|
|
243
|
+
messager.to_sgid(expires_in: nil, for: :chats_inbox_with).to_s
|
|
244
|
+
end
|
|
245
|
+
|
|
159
246
|
# --- Internals ------------------------------------------------------------
|
|
160
247
|
|
|
161
248
|
def logger
|
|
@@ -172,6 +259,18 @@ module Chats
|
|
|
172
259
|
|
|
173
260
|
private
|
|
174
261
|
|
|
262
|
+
# Ask a messager's CLASS about an `acts_as_messager` option. Duck-typed
|
|
263
|
+
# (never `is_a?`): anything that doesn't answer is treated as a stock
|
|
264
|
+
# messager, so a plain host model keeps 0.1.x behaviour.
|
|
265
|
+
def messager_option(messager, predicate, default: true)
|
|
266
|
+
return default if messager.nil?
|
|
267
|
+
|
|
268
|
+
klass = messager.is_a?(Class) ? messager : messager.class
|
|
269
|
+
return default unless klass.respond_to?(predicate)
|
|
270
|
+
|
|
271
|
+
klass.public_send(predicate)
|
|
272
|
+
end
|
|
273
|
+
|
|
175
274
|
def registered_class?(registry, klass)
|
|
176
275
|
klass = klass.class unless klass.is_a?(Class) || klass.is_a?(String)
|
|
177
276
|
name = klass.is_a?(String) ? klass : klass.name
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# chats 0.2.0 — message AUTHORSHIP.
|
|
4
|
+
#
|
|
5
|
+
# `sender` is the seat a message came from; `author` is who wrote it on that
|
|
6
|
+
# seat's behalf (an agent answering from a shared support desk). Both
|
|
7
|
+
# nullable, both polymorphic, indexed together because they're always read
|
|
8
|
+
# together. See Chats::Message#signed?.
|
|
9
|
+
#
|
|
10
|
+
# Safe to run on an install that already has the columns (a 0.2.0 fresh
|
|
11
|
+
# install does): every step is guarded, so this migration is a no-op there.
|
|
12
|
+
class AddAuthorToChatsMessages < ActiveRecord::Migration<%= migration_version %>
|
|
13
|
+
def up
|
|
14
|
+
unless column_exists?(:chats_messages, :author_type)
|
|
15
|
+
add_reference :chats_messages, :author, polymorphic: true, null: true,
|
|
16
|
+
type: chats_foreign_key_type, index: false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
return if index_exists?(:chats_messages, [ :author_type, :author_id ], name: "index_chats_messages_on_author")
|
|
20
|
+
|
|
21
|
+
add_index :chats_messages, [ :author_type, :author_id ], name: "index_chats_messages_on_author"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def down
|
|
25
|
+
if index_exists?(:chats_messages, [ :author_type, :author_id ], name: "index_chats_messages_on_author")
|
|
26
|
+
remove_index :chats_messages, name: "index_chats_messages_on_author"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return unless column_exists?(:chats_messages, :author_type)
|
|
30
|
+
|
|
31
|
+
remove_column :chats_messages, :author_type
|
|
32
|
+
remove_column :chats_messages, :author_id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Honor the host's configured primary key type (uuid vs bigint), exactly
|
|
38
|
+
# like the install migration does — the author columns must match the key
|
|
39
|
+
# type of the models they point at.
|
|
40
|
+
def chats_foreign_key_type
|
|
41
|
+
config = Rails.configuration.generators
|
|
42
|
+
config.options[config.orm][:primary_key_type] || :bigint
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -88,6 +88,12 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
88
88
|
foreign_key: { to_table: :chats_conversations }, index: false
|
|
89
89
|
t.references :sender, polymorphic: true, null: true, type: foreign_key_type, index: false
|
|
90
90
|
|
|
91
|
+
# Who WROTE it, when that isn't the seat it was sent FROM: an agent
|
|
92
|
+
# answering from a shared support-desk seat signs the bubble while the
|
|
93
|
+
# desk stays the conversation identity. Nullable — ordinary messages
|
|
94
|
+
# have no author. See Chats::Message#signed?.
|
|
95
|
+
t.references :author, polymorphic: true, null: true, type: foreign_key_type, index: false
|
|
96
|
+
|
|
91
97
|
t.string :kind, null: false, default: "text"
|
|
92
98
|
t.text :body
|
|
93
99
|
t.references :reply_to, type: foreign_key_type, null: true,
|
|
@@ -103,6 +109,7 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
103
109
|
# (created_at, id) — see Chats::Message.before_message.
|
|
104
110
|
add_index :chats_messages, [ :conversation_id, :created_at, :id ], name: "index_chats_messages_on_conversation_and_created_at"
|
|
105
111
|
add_index :chats_messages, [ :sender_type, :sender_id ], name: "index_chats_messages_on_sender"
|
|
112
|
+
add_index :chats_messages, [ :author_type, :author_id ], name: "index_chats_messages_on_author"
|
|
106
113
|
add_index :chats_messages, :reply_to_id, name: "index_chats_messages_on_reply_to_id"
|
|
107
114
|
|
|
108
115
|
# ---------------------------------------------------------------------------
|
|
@@ -142,8 +149,12 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
142
149
|
[primary_key_type, foreign_key_type]
|
|
143
150
|
end
|
|
144
151
|
|
|
152
|
+
# jsonb on every PostgreSQL adapter — matched by prefix because PostGIS
|
|
153
|
+
# (activerecord-postgis-adapter) answers "PostGIS", not "PostgreSQL", and
|
|
154
|
+
# an `include?("postgresql")` check silently sent such hosts down the plain
|
|
155
|
+
# json path.
|
|
145
156
|
def json_column_type
|
|
146
|
-
return :jsonb if connection.adapter_name.
|
|
157
|
+
return :jsonb if connection.adapter_name.match?(/\Apostg/i)
|
|
147
158
|
|
|
148
159
|
:json
|
|
149
160
|
end
|
|
@@ -151,8 +162,13 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
151
162
|
# MySQL 8+ doesn't allow default values on JSON columns.
|
|
152
163
|
# Returns an empty-hash default for SQLite/PostgreSQL, nil for MySQL.
|
|
153
164
|
# The model handles nil metadata gracefully (attribute default {}).
|
|
165
|
+
#
|
|
166
|
+
# Trilogy is MySQL under a different ADAPTER_NAME (Rails reports "Trilogy"),
|
|
167
|
+
# so match both — a /mysql/ pattern alone hands a Trilogy host a default
|
|
168
|
+
# MySQL rejects. api_keys hit this first; see its create_api_keys_table
|
|
169
|
+
# template, which matches /mysql|trilogy/.
|
|
154
170
|
def json_column_default
|
|
155
|
-
return nil if connection.adapter_name.
|
|
171
|
+
return nil if connection.adapter_name.match?(/mysql|trilogy/i)
|
|
156
172
|
|
|
157
173
|
{}
|
|
158
174
|
end
|
|
@@ -14,6 +14,20 @@ Chats.configure do |config|
|
|
|
14
14
|
# Default: "User"
|
|
15
15
|
config.messager_class = "User"
|
|
16
16
|
|
|
17
|
+
# Any model can converse, and a model that isn't a person can say so:
|
|
18
|
+
#
|
|
19
|
+
# class SupportDesk < ApplicationRecord
|
|
20
|
+
# acts_as_messager notifications: false, # never notifiable
|
|
21
|
+
# blockable: false, # no block/report affordances
|
|
22
|
+
# inbox: :grouped # every thread with it is ONE
|
|
23
|
+
# # inbox row (a "stack")
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# `group_path:` says where that stacked row goes when it holds more than
|
|
27
|
+
# one conversation (default: chats' own filtered inbox):
|
|
28
|
+
#
|
|
29
|
+
# acts_as_messager inbox: :grouped, group_path: ->(viewer) { support_path }
|
|
30
|
+
|
|
17
31
|
# ==========================================================================
|
|
18
32
|
# CONTROLLER INTEGRATION
|
|
19
33
|
# ==========================================================================
|
|
@@ -62,6 +76,12 @@ Chats.configure do |config|
|
|
|
62
76
|
#
|
|
63
77
|
# config.send_rate_limit = { to: 60, within: 1.minute }
|
|
64
78
|
#
|
|
79
|
+
# How many conversations the inbox loads (and therefore how deep search
|
|
80
|
+
# and stacking see). The inbox is a "recent activity" surface, not an
|
|
81
|
+
# archive.
|
|
82
|
+
#
|
|
83
|
+
# config.inbox_limit = 200
|
|
84
|
+
#
|
|
65
85
|
# Encrypt message bodies at rest (ActiveRecord Encryption; requires
|
|
66
86
|
# `bin/rails db:encryption:init`). Body search degrades when enabled.
|
|
67
87
|
#
|
|
@@ -80,6 +100,23 @@ Chats.configure do |config|
|
|
|
80
100
|
# }
|
|
81
101
|
#
|
|
82
102
|
# config.can_create_group = ->(creator) { creator.admin? }
|
|
103
|
+
#
|
|
104
|
+
# Composed into the inbox query before the limit — hide rows, re-scope
|
|
105
|
+
# them, whatever your product needs, without overriding the controller:
|
|
106
|
+
#
|
|
107
|
+
# config.inbox_scope = ->(relation, viewer) { relation }
|
|
108
|
+
#
|
|
109
|
+
# Whether a conversation still accepts messages is NOT a proc: the SUBJECT
|
|
110
|
+
# owns it, because the subject already owns the conversation's meaning.
|
|
111
|
+
#
|
|
112
|
+
# class Ticket < ApplicationRecord
|
|
113
|
+
# acts_as_chat_subject
|
|
114
|
+
# def chat_locked? = closed?
|
|
115
|
+
# def chat_locked_notice = "This ticket is closed. Reply to reopen it."
|
|
116
|
+
# end
|
|
117
|
+
#
|
|
118
|
+
# Locking gates SENDING only: the thread stays readable and the composer
|
|
119
|
+
# is replaced by the notice (see the `locked_composer` slot below).
|
|
83
120
|
|
|
84
121
|
# ==========================================================================
|
|
85
122
|
# TRUST & SAFETY — snap onto the `moderate` gem (or anything else)
|
|
@@ -103,25 +140,35 @@ Chats.configure do |config|
|
|
|
103
140
|
# config.filter "Chats::Message", :body, mode: :flag
|
|
104
141
|
|
|
105
142
|
# ==========================================================================
|
|
106
|
-
#
|
|
143
|
+
# EVENTS — subscribe to the domain moments, fan out anywhere
|
|
107
144
|
# ==========================================================================
|
|
108
145
|
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
146
|
+
# Many subscribers per event; each runs isolated (a raising one is reported
|
|
147
|
+
# through Rails.error and never breaks message delivery). Keep them fast —
|
|
148
|
+
# enqueue jobs, don't do work inline.
|
|
111
149
|
#
|
|
112
|
-
# :message_created
|
|
113
|
-
# :
|
|
150
|
+
# Chats.on(:message_created) { |message| } # every human message
|
|
151
|
+
# Chats.on(:conversation_created) { |conversation| } # a thread came into being
|
|
152
|
+
# Chats.on(:participant_left) { |participant| } # someone left a group
|
|
153
|
+
# Chats.on(:conversation_read) { |conversation:, participant:| }
|
|
114
154
|
#
|
|
115
155
|
# With Noticed:
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
156
|
+
# Chats.on(:message_created) { |message| NewMessageNotifier.with(record: message).deliver }
|
|
157
|
+
#
|
|
158
|
+
# With a debounced-email job (see Chats::Participant#should_notify? for the
|
|
159
|
+
# "only email once until they come back" etiquette helper):
|
|
160
|
+
# Chats.on(:message_created) { |message| ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) }
|
|
161
|
+
#
|
|
162
|
+
# Registering from reloadable code? Pass a key, and a reload replaces the
|
|
163
|
+
# subscriber instead of stacking a second one:
|
|
164
|
+
#
|
|
165
|
+
# Rails.application.config.to_prepare do
|
|
166
|
+
# Chats.on(:message_created, key: :unread_email) { |message| … }
|
|
167
|
+
# end
|
|
168
|
+
#
|
|
169
|
+
# DEPRECATED (removed in 1.0): `config.notifier = ->(event, **payload) {}`
|
|
170
|
+
# still works, but receives :message_created and :conversation_read only —
|
|
171
|
+
# the events 0.1.1 had. The ones added in 0.2.0 are Chats.on-only.
|
|
125
172
|
|
|
126
173
|
# ==========================================================================
|
|
127
174
|
# DISPLAY — how messagers appear in the bundled views
|
|
@@ -135,4 +182,38 @@ Chats.configure do |config|
|
|
|
135
182
|
# config.messager_avatar = ->(messager) {
|
|
136
183
|
# messager.avatar.attached? ? messager.avatar.variant(:thumb) : nil
|
|
137
184
|
# }
|
|
185
|
+
#
|
|
186
|
+
# Where a messager's profile lives. nil (the default) means the bundled
|
|
187
|
+
# views render names as plain text — chats never assumes you have a
|
|
188
|
+
# `user_path`, and never renders a dead anchor:
|
|
189
|
+
#
|
|
190
|
+
# config.messager_url = lambda do |messager|
|
|
191
|
+
# routes = Rails.application.routes.url_helpers
|
|
192
|
+
#
|
|
193
|
+
# case messager
|
|
194
|
+
# when User then routes.user_path(messager) # a desk or a bot has no profile: nil
|
|
195
|
+
# end
|
|
196
|
+
# end
|
|
197
|
+
#
|
|
198
|
+
# The signature under a message written by an AUTHOR on a sender's behalf
|
|
199
|
+
# (`desk.message!(user, "On it!", author: agent)`). Defaults to the
|
|
200
|
+
# localized "— Agent Name":
|
|
201
|
+
#
|
|
202
|
+
# config.message_signature = ->(message) { "answered by #{message.author.first_name}" }
|
|
203
|
+
|
|
204
|
+
# ==========================================================================
|
|
205
|
+
# SLOTS — add one row or one button without ejecting a screen
|
|
206
|
+
# ==========================================================================
|
|
207
|
+
#
|
|
208
|
+
# The bundled views render a partial named `chats/slots/_<slot>` whenever
|
|
209
|
+
# one exists in your app. No configuration, no registration: create the
|
|
210
|
+
# file and it appears.
|
|
211
|
+
#
|
|
212
|
+
# app/views/chats/slots/_inbox_top.html.erb above the first inbox row
|
|
213
|
+
# app/views/chats/slots/_inbox_empty.html.erb inside the empty state
|
|
214
|
+
# app/views/chats/slots/_conversation_header_actions.html.erb thread menu
|
|
215
|
+
# app/views/chats/slots/_locked_composer.html.erb the locked composer's body
|
|
216
|
+
# app/views/chats/slots/_message_meta.html.erb after each bubble's timestamp
|
|
217
|
+
#
|
|
218
|
+
# `rails generate chats:views` is still there for wholesale restyling.
|
|
138
219
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Chats
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails generate chats:upgrade` — copy the migrations a version bump
|
|
9
|
+
# needs into an EXISTING install. Nothing else: the initializer, the
|
|
10
|
+
# views and the routes you already own stay untouched.
|
|
11
|
+
#
|
|
12
|
+
# Currently writes the 0.2.0 migration (message authorship). It is
|
|
13
|
+
# written guarded, so running it against an install that already has the
|
|
14
|
+
# columns (a fresh 0.2.0 install) is a no-op rather than an error.
|
|
15
|
+
class UpgradeGenerator < Rails::Generators::Base
|
|
16
|
+
include ActiveRecord::Generators::Migration
|
|
17
|
+
|
|
18
|
+
source_root File.expand_path("templates", __dir__)
|
|
19
|
+
desc "Add the migrations a chats version bump needs (0.2.0: message authorship)"
|
|
20
|
+
|
|
21
|
+
def self.next_migration_number(dir)
|
|
22
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_author_migration
|
|
26
|
+
migration_template "add_author_to_chats_messages.rb.erb",
|
|
27
|
+
File.join(db_migrate_path, "add_author_to_chats_messages.rb")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def display_post_upgrade_message
|
|
31
|
+
say "\n💬 chats upgrade migrations copied.", :green
|
|
32
|
+
say "\n 1. Run 'rails db:migrate'."
|
|
33
|
+
say " 2. New in 0.2.0 — all opt-in, nothing changes until you ask:"
|
|
34
|
+
say " acts_as_messager notifications: false, blockable: false, inbox: :grouped"
|
|
35
|
+
say " Chats.on(:message_created) { |message| … } # replaces config.notifier"
|
|
36
|
+
say " config.messager_url / config.message_signature / config.inbox_scope"
|
|
37
|
+
say " chat_locked? / chat_locked_notice on your chat subjects"
|
|
38
|
+
say " 3. See the CHANGELOG for the full list.\n", :green
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def migration_version
|
|
44
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|