chats 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +233 -0
- data/README.md +214 -28
- data/app/assets/stylesheets/chats.css +86 -0
- data/app/controllers/chats/conversations_controller.rb +39 -42
- 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 +113 -2
- data/app/javascript/chats/refresh_inbox_controller.js +86 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
- data/app/views/chats/conversations/_group.html.erb +41 -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 +48 -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/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +13 -0
- data/config/locales/es.yml +13 -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 +79 -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 +33 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +99 -2
- data/lib/chats/models/conversation.rb +78 -7
- 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 +122 -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 +106 -14
- data/lib/generators/chats/upgrade_generator.rb +48 -0
- metadata +13 -2
|
@@ -141,7 +141,12 @@ module Chats
|
|
|
141
141
|
# `create_or_find_by!` may have FOUND a conversation created a moment
|
|
142
142
|
# ago by the other side — participants are ensured idempotently
|
|
143
143
|
# either way (their own unique index makes this race-safe too).
|
|
144
|
+
# `previously_new_record?` is how we tell the two apart, so the
|
|
145
|
+
# :conversation_created event fires ONCE per conversation, not on
|
|
146
|
+
# every resume.
|
|
147
|
+
created = conversation.previously_new_record?
|
|
144
148
|
[a, b].each { |messager| conversation.add_participant!(messager) }
|
|
149
|
+
Chats.notify(:conversation_created, conversation: conversation) if created
|
|
145
150
|
conversation
|
|
146
151
|
end
|
|
147
152
|
|
|
@@ -158,12 +163,17 @@ module Chats
|
|
|
158
163
|
others = Array(others) - [creator]
|
|
159
164
|
raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
|
|
160
165
|
|
|
161
|
-
transaction do
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
others.each { |messager|
|
|
165
|
-
|
|
166
|
+
conversation = transaction do
|
|
167
|
+
created = create!(kind: "group", title: title, subject: about)
|
|
168
|
+
created.add_participant!(creator, role: "owner")
|
|
169
|
+
others.each { |messager| created.add_participant!(messager) }
|
|
170
|
+
created
|
|
166
171
|
end
|
|
172
|
+
|
|
173
|
+
# Emitted AFTER the transaction: subscribers see a complete roster
|
|
174
|
+
# and never run inside the write that created it.
|
|
175
|
+
Chats.notify(:conversation_created, conversation: conversation)
|
|
176
|
+
conversation
|
|
167
177
|
end
|
|
168
178
|
|
|
169
179
|
# Deterministic identity for a direct pair (+ optional subject).
|
|
@@ -212,13 +222,30 @@ module Chats
|
|
|
212
222
|
)
|
|
213
223
|
end
|
|
214
224
|
|
|
225
|
+
# The OTHER messager in a direct thread, from +viewer+'s seat — nil for a
|
|
226
|
+
# group, and nil for a direct thread whose other seat has left. The one
|
|
227
|
+
# place the counterpart is resolved, so the title, the avatar and the
|
|
228
|
+
# verified badge on an inbox row always name the same person.
|
|
229
|
+
#
|
|
230
|
+
# Memoized per viewer: an inbox row asks for it two or three times, and a
|
|
231
|
+
# row that cost one query in 0.2.0 must not start costing three.
|
|
232
|
+
def counterpart_for(viewer)
|
|
233
|
+
return nil unless direct?
|
|
234
|
+
|
|
235
|
+
@counterparts ||= {}
|
|
236
|
+
key = viewer && Chats.messager_key(viewer)
|
|
237
|
+
return @counterparts[key] if @counterparts.key?(key)
|
|
238
|
+
|
|
239
|
+
@counterparts[key] = other_participants(viewer).includes(:messager).first&.messager
|
|
240
|
+
end
|
|
241
|
+
|
|
215
242
|
# What this conversation is called from +viewer+'s seat: a direct thread
|
|
216
243
|
# is named after the counterpart; a group after its title (or its
|
|
217
244
|
# members, when untitled).
|
|
218
245
|
def title_for(viewer)
|
|
219
246
|
if direct?
|
|
220
|
-
other =
|
|
221
|
-
other ? Chats.display_name_for(other
|
|
247
|
+
other = counterpart_for(viewer)
|
|
248
|
+
other ? Chats.display_name_for(other) : I18n.t("chats.conversation.empty_title")
|
|
222
249
|
else
|
|
223
250
|
title.presence || participants.active.includes(:messager).limit(4).map do |p|
|
|
224
251
|
Chats.display_name_for(p.messager)
|
|
@@ -235,6 +262,31 @@ module Chats
|
|
|
235
262
|
subject.try(:chat_subject_label) || "#{subject.class.model_name.human} #{subject.id}"
|
|
236
263
|
end
|
|
237
264
|
|
|
265
|
+
# Whether new messages are refused here, decided by the SUBJECT (see
|
|
266
|
+
# Chats::ChatSubject#chat_locked?). A subjectless conversation is never
|
|
267
|
+
# locked. Reading is never affected — only sending.
|
|
268
|
+
def locked?
|
|
269
|
+
return false if subject.nil?
|
|
270
|
+
|
|
271
|
+
subject.try(:chat_locked?) || false
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The host's explanation for the lock, or the gem's localized fallback.
|
|
275
|
+
# Always a sentence worth showing: a locked composer that says nothing is
|
|
276
|
+
# indistinguishable from a broken one.
|
|
277
|
+
def locked_notice
|
|
278
|
+
return nil unless locked?
|
|
279
|
+
|
|
280
|
+
subject.try(:chat_locked_notice).presence || I18n.t("chats.composer.locked")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The guard every write that ISN'T a message itself calls (reactions
|
|
284
|
+
# today). Message writes go through Chats::Message#refuse_when_locked!,
|
|
285
|
+
# which exempts system messages.
|
|
286
|
+
def refuse_writes_when_locked! # :nodoc:
|
|
287
|
+
raise Chats::LockedError.new(conversation: self) if locked?
|
|
288
|
+
end
|
|
289
|
+
|
|
238
290
|
# --- Membership -----------------------------------------------------------
|
|
239
291
|
|
|
240
292
|
# Idempotent, race-safe membership. Re-adding someone who left re-joins
|
|
@@ -250,6 +302,25 @@ module Chats
|
|
|
250
302
|
participant
|
|
251
303
|
end
|
|
252
304
|
|
|
305
|
+
# Recompute the deterministic identity of a DIRECT thread after its
|
|
306
|
+
# roster changed (see Chats::Participant#reseat!). Without this the key
|
|
307
|
+
# would still name the old pair, and `chat_with` would open a SECOND
|
|
308
|
+
# thread for the new one. No-op for groups (they have no key).
|
|
309
|
+
def reindex_direct_key! # :nodoc:
|
|
310
|
+
return self unless direct?
|
|
311
|
+
|
|
312
|
+
# `reset`: the caller just changed a seat, and a participants
|
|
313
|
+
# association loaded BEFORE that would name the old pair.
|
|
314
|
+
messagers = participants.reset.includes(:messager).filter_map(&:messager)
|
|
315
|
+
return self unless messagers.size == 2
|
|
316
|
+
|
|
317
|
+
update_columns(
|
|
318
|
+
direct_key: self.class.direct_key_for(messagers, subject: subject),
|
|
319
|
+
updated_at: Time.current
|
|
320
|
+
)
|
|
321
|
+
self
|
|
322
|
+
end
|
|
323
|
+
|
|
253
324
|
# --- Messaging ------------------------------------------------------------
|
|
254
325
|
|
|
255
326
|
# Post a message from your APP into the conversation — "Your ride was
|
data/lib/chats/models/message.rb
CHANGED
|
@@ -33,6 +33,11 @@ module Chats
|
|
|
33
33
|
inverse_of: :messages,
|
|
34
34
|
counter_cache: :messages_count
|
|
35
35
|
belongs_to :sender, polymorphic: true, optional: true
|
|
36
|
+
# The person (or bot) who WROTE this on the sender's behalf — an agent
|
|
37
|
+
# answering from a shared support-desk seat. The sender stays the
|
|
38
|
+
# conversation identity ("Soporte CarHey"); the author signs the bubble
|
|
39
|
+
# ("— Lucía G."). Optional, and nil for every ordinary message.
|
|
40
|
+
belongs_to :author, polymorphic: true, optional: true
|
|
36
41
|
belongs_to :reply_to, class_name: "Chats::Message", optional: true
|
|
37
42
|
|
|
38
43
|
has_many :reactions,
|
|
@@ -90,6 +95,8 @@ module Chats
|
|
|
90
95
|
validate :body_must_fit_length_limit
|
|
91
96
|
validate :sender_must_be_active_participant, on: :create
|
|
92
97
|
validate :sender_must_not_be_blocked, on: :create
|
|
98
|
+
validate :conversation_must_not_be_locked, on: :create
|
|
99
|
+
validate :author_must_be_a_messager
|
|
93
100
|
validate :files_must_be_allowed
|
|
94
101
|
|
|
95
102
|
after_create :register_on_conversation
|
|
@@ -112,6 +119,19 @@ module Chats
|
|
|
112
119
|
sender.present? && sender == messager
|
|
113
120
|
end
|
|
114
121
|
|
|
122
|
+
# Written by someone OTHER than the seat it was sent from — the case a
|
|
123
|
+
# signature exists for. A message an author sent from their own seat is
|
|
124
|
+
# not "signed"; it's just theirs.
|
|
125
|
+
def signed?
|
|
126
|
+
author.present? && author != sender
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Whether +messager+ is the one who WROTE this (not necessarily the seat
|
|
130
|
+
# it was sent from).
|
|
131
|
+
def authored_by?(messager)
|
|
132
|
+
author.present? && author == messager
|
|
133
|
+
end
|
|
134
|
+
|
|
115
135
|
# The body as the UI should show it (tombstones render a localized
|
|
116
136
|
# "Message deleted" placeholder straight from the view, not from here —
|
|
117
137
|
# this just guards against showing stale bodies by accident).
|
|
@@ -133,12 +153,19 @@ module Chats
|
|
|
133
153
|
raise Chats::NotAllowedError, "editing is disabled" unless Chats.config.editing
|
|
134
154
|
raise Chats::NotAllowedError, "can't edit a deleted message" if deleted?
|
|
135
155
|
|
|
156
|
+
refuse_when_locked!
|
|
136
157
|
update!(body: new_body, edited_at: Time.current)
|
|
137
158
|
end
|
|
138
159
|
|
|
139
160
|
# Delete according to `config.deletion` (see class comment). Returns
|
|
140
161
|
# false when deletion is disabled.
|
|
141
|
-
|
|
162
|
+
#
|
|
163
|
+
# `enforce_lock: false` is for MODERATION only (see
|
|
164
|
+
# #remove_reported_field!): a product lock must never shield reported
|
|
165
|
+
# content from removal.
|
|
166
|
+
def soft_delete!(enforce_lock: true)
|
|
167
|
+
refuse_when_locked! if enforce_lock
|
|
168
|
+
|
|
142
169
|
case Chats.config.deletion
|
|
143
170
|
when :soft
|
|
144
171
|
transaction do
|
|
@@ -158,6 +185,16 @@ module Chats
|
|
|
158
185
|
respond_to?(:files) && files.attached?
|
|
159
186
|
end
|
|
160
187
|
|
|
188
|
+
# Every WRITE to an existing message goes through here, for the same
|
|
189
|
+
# reason `create` validates the lock: a closed conversation is closed for
|
|
190
|
+
# editing and deleting too, not just for new messages. System messages
|
|
191
|
+
# stay exempt — the app owns them.
|
|
192
|
+
def refuse_when_locked! # :nodoc:
|
|
193
|
+
return if system? || conversation.nil? || !conversation.locked?
|
|
194
|
+
|
|
195
|
+
raise Chats::LockedError.new(conversation: conversation)
|
|
196
|
+
end
|
|
197
|
+
|
|
161
198
|
# --- Moderation contract (duck-typed, zero coupling) ------------------------
|
|
162
199
|
#
|
|
163
200
|
# Plain-Ruby methods that make a message a first-class citizen of the
|
|
@@ -167,8 +204,14 @@ module Chats
|
|
|
167
204
|
# seams for attachment filtering. Without moderate installed they're
|
|
168
205
|
# inert and cost nothing.
|
|
169
206
|
|
|
207
|
+
# Who answers for this message: its AUTHOR when it is signed, else its
|
|
208
|
+
# sender. A signed message was written by a human from a seat that is not
|
|
209
|
+
# a person — an agent answering from a support desk — and the seat cannot
|
|
210
|
+
# be the owner a moderation flag or report points at: the host's `owner`
|
|
211
|
+
# is typed to its user class, so a desk there is a type mismatch raised
|
|
212
|
+
# from inside the agent's own reply the first time a filter trips.
|
|
170
213
|
def reported_owner
|
|
171
|
-
sender
|
|
214
|
+
author || sender
|
|
172
215
|
end
|
|
173
216
|
|
|
174
217
|
def moderation_label
|
|
@@ -189,7 +232,9 @@ module Chats
|
|
|
189
232
|
def remove_reported_field!(field)
|
|
190
233
|
return false unless field.to_s == "body"
|
|
191
234
|
|
|
192
|
-
|
|
235
|
+
# Trust & Safety outranks a product lock: a closed conversation must
|
|
236
|
+
# never be a place reported content can hide.
|
|
237
|
+
soft_delete!(enforce_lock: false)
|
|
193
238
|
end
|
|
194
239
|
|
|
195
240
|
# Only people *in* the conversation may report a message (a message
|
|
@@ -267,6 +312,25 @@ module Chats
|
|
|
267
312
|
errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
|
|
268
313
|
end
|
|
269
314
|
|
|
315
|
+
# An author signs the bubble with `Chats.display_name_for`, so it has to
|
|
316
|
+
# be something that HAS a name in this system — a messager, not a ride or
|
|
317
|
+
# a listing that would render as "Listing 1".
|
|
318
|
+
def author_must_be_a_messager
|
|
319
|
+
return if author.nil? || Chats.messager_class?(author.class)
|
|
320
|
+
|
|
321
|
+
errors.add(:author, :not_a_messager)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# The subject owns the conversation's openness (Chats::ChatSubject#
|
|
325
|
+
# chat_locked?). System messages are exempt: the host must always be able
|
|
326
|
+
# to post "This ticket was closed" into the thread it just closed.
|
|
327
|
+
def conversation_must_not_be_locked
|
|
328
|
+
return if system? || conversation.nil?
|
|
329
|
+
return unless conversation.locked?
|
|
330
|
+
|
|
331
|
+
errors.add(:base, :locked)
|
|
332
|
+
end
|
|
333
|
+
|
|
270
334
|
def files_must_be_allowed
|
|
271
335
|
return unless respond_to?(:files)
|
|
272
336
|
return unless files.attached?
|
|
@@ -105,6 +105,37 @@ module Chats
|
|
|
105
105
|
|
|
106
106
|
def leave!
|
|
107
107
|
update!(left_at: Time.current)
|
|
108
|
+
Chats.notify(:participant_left, participant: self)
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Hand this seat to a different messager, keeping the read horizon, the
|
|
113
|
+
# role and the history: the guest who signs up, the agent who takes over
|
|
114
|
+
# a shared mailbox. The MESSAGES keep their original sender — what was
|
|
115
|
+
# said was said by whoever said it.
|
|
116
|
+
#
|
|
117
|
+
# Direct conversations have their +direct_key+ recomputed, so the thread
|
|
118
|
+
# keeps resolving through `chat_with` for the NEW pair instead of
|
|
119
|
+
# stranding a duplicate.
|
|
120
|
+
def reseat!(new_messager)
|
|
121
|
+
raise ArgumentError, "reseat! requires a messager" if new_messager.nil?
|
|
122
|
+
unless Chats.messager_class?(new_messager.class)
|
|
123
|
+
raise Chats::NotAllowedError, "#{new_messager.class.name} is not a messager (acts_as_messager)"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
refuse_reseat_conflicts!(new_messager)
|
|
127
|
+
|
|
128
|
+
transaction do
|
|
129
|
+
update!(messager: new_messager)
|
|
130
|
+
conversation.reindex_direct_key!
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
self
|
|
134
|
+
rescue ActiveRecord::RecordNotUnique
|
|
135
|
+
# The race backstop for the checks above (two reseats, or a DM opened,
|
|
136
|
+
# between the check and the write). Translated so a host never has a
|
|
137
|
+
# driver-level exception poison its transaction.
|
|
138
|
+
raise Chats::NotAllowedError, "that conversation already exists for the new pair"
|
|
108
139
|
end
|
|
109
140
|
|
|
110
141
|
# --- Notification etiquette (for host notifier hooks) ----------------------
|
|
@@ -114,6 +145,10 @@ module Chats
|
|
|
114
145
|
# re-derive it: don't notify yourself, the muted, the departed — and for
|
|
115
146
|
# debounced email digests, don't notify twice for the same unread burst.
|
|
116
147
|
def notifiable_for?(message)
|
|
148
|
+
# Headless messagers (`acts_as_messager notifications: false`) — a
|
|
149
|
+
# support desk, a bot, an org mailbox — are never notifiable. This is
|
|
150
|
+
# THE reason hosts no longer branch on class in their notifiers.
|
|
151
|
+
return false unless Chats.notifications_for?(messager)
|
|
117
152
|
return false if left? || muted?
|
|
118
153
|
return false if message.sender == messager
|
|
119
154
|
|
|
@@ -135,6 +170,30 @@ module Chats
|
|
|
135
170
|
|
|
136
171
|
private
|
|
137
172
|
|
|
173
|
+
# Everything that would make the reseat collide, checked BEFORE the
|
|
174
|
+
# write. A unique-index violation inside the transaction would abort the
|
|
175
|
+
# host's transaction too on PostgreSQL, so the pre-check is the real
|
|
176
|
+
# guard and the RecordNotUnique rescue is only the race backstop.
|
|
177
|
+
def refuse_reseat_conflicts!(new_messager)
|
|
178
|
+
if conversation.participants.where.not(id: id).exists?(
|
|
179
|
+
messager_type: new_messager.class.polymorphic_name, messager_id: new_messager.id
|
|
180
|
+
)
|
|
181
|
+
raise Chats::NotAllowedError, "that messager already has a seat in this conversation"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
return unless conversation.direct?
|
|
185
|
+
|
|
186
|
+
other = conversation.other_participants(messager).includes(:messager).first&.messager
|
|
187
|
+
return if other.nil?
|
|
188
|
+
|
|
189
|
+
existing = Chats::Conversation.direct_between(new_messager, other, about: conversation.subject)
|
|
190
|
+
return if existing.nil? || existing == conversation
|
|
191
|
+
|
|
192
|
+
raise Chats::NotAllowedError,
|
|
193
|
+
"#{Chats.display_name_for(new_messager)} already has a direct conversation with " \
|
|
194
|
+
"#{Chats.display_name_for(other)}"
|
|
195
|
+
end
|
|
196
|
+
|
|
138
197
|
def group_must_have_room
|
|
139
198
|
return if conversation.nil? || conversation.direct?
|
|
140
199
|
|
|
@@ -32,6 +32,11 @@ module Chats
|
|
|
32
32
|
# toggled off. Race-safe: a concurrent double-tap resolves through the
|
|
33
33
|
# unique index instead of raising.
|
|
34
34
|
def self.toggle!(message:, reactor:, emoji:)
|
|
35
|
+
# Reacting is a write to the conversation, so a locked one refuses it —
|
|
36
|
+
# in BOTH directions: you can't add a reaction to a closed thread, and
|
|
37
|
+
# you can't take one back either. Same rule as editing and deleting.
|
|
38
|
+
message&.conversation&.refuse_writes_when_locked!
|
|
39
|
+
|
|
35
40
|
existing = find_by(message: message, reactor: reactor, emoji: emoji)
|
|
36
41
|
if existing
|
|
37
42
|
existing.destroy!
|
|
@@ -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