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,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# A stack of direct conversations that share one counterpart, shown as a
|
|
5
|
+
# SINGLE inbox row. Built by Chats::Inbox for messagers declared with
|
|
6
|
+
# `acts_as_messager inbox: :grouped` — a support desk, a marketplace
|
|
7
|
+
# storefront, any seat a person ends up with many separate threads with.
|
|
8
|
+
#
|
|
9
|
+
# It quacks like the parts of Chats::Conversation the inbox row needs
|
|
10
|
+
# (+last_message+, +last_message_at+, +unread_count+) so the two row
|
|
11
|
+
# partials stay symmetrical.
|
|
12
|
+
#
|
|
13
|
+
# +unread_count+ and +open_count+ are GLOBAL: they describe the whole
|
|
14
|
+
# stack, however deep it runs, not the bounded window in +conversations+.
|
|
15
|
+
# Chats::Inbox reads them with two indexed aggregates per stack.
|
|
16
|
+
class InboxGroup
|
|
17
|
+
# +conversations+ is the loaded WINDOW of the stack (freshest first,
|
|
18
|
+
# bounded by config.inbox_limit); +open_count+ and +unread_count+ describe
|
|
19
|
+
# the whole stack, however deep it runs.
|
|
20
|
+
attr_reader :messager, :conversations, :unread_count, :open_count
|
|
21
|
+
|
|
22
|
+
def initialize(messager:, conversations:, unread_count: 0, open_count: nil)
|
|
23
|
+
@messager = messager
|
|
24
|
+
@conversations = conversations
|
|
25
|
+
@unread_count = unread_count
|
|
26
|
+
@open_count = open_count || conversations.size
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A stack of one is really just a conversation: the row links straight to
|
|
30
|
+
# it, and the thread carries a "see all" link back to the stack.
|
|
31
|
+
def single?
|
|
32
|
+
open_count == 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The freshest conversation in the stack — what the row previews, and
|
|
36
|
+
# what it links to when the stack holds exactly one.
|
|
37
|
+
def conversation
|
|
38
|
+
conversations.first
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The stack's most recent message (the row's preview line).
|
|
42
|
+
def last_message
|
|
43
|
+
conversation&.last_message
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The sort key the inbox orders rows by: the freshest activity in the
|
|
47
|
+
# stack (mirrors Conversation.recent_first's COALESCE).
|
|
48
|
+
def last_message_at
|
|
49
|
+
conversations.filter_map { |c| c.last_message_at || c.created_at }.max
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether the stack has anything unread in it, anywhere.
|
|
53
|
+
def unread?
|
|
54
|
+
unread_count.positive?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What the row is called. A stack is named after its counterpart from
|
|
58
|
+
# every seat, so the viewer is accepted and ignored — the signature
|
|
59
|
+
# matches Chats::Conversation#title_for so both row partials can call it.
|
|
60
|
+
def title_for(_viewer)
|
|
61
|
+
Chats.display_name_for(messager)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Stable DOM id for the row (no AR record to derive one from).
|
|
65
|
+
def dom_id
|
|
66
|
+
"chats_inbox_group_#{messager.class.polymorphic_name.underscore.tr("/", "_")}_#{messager.id}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The signed, purpose-scoped GlobalID that filters the inbox to this
|
|
70
|
+
# stack (`GET /conversations?with=…`).
|
|
71
|
+
def with_sgid
|
|
72
|
+
Chats.inbox_with_sgid(messager)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/chats/macros.rb
CHANGED
|
@@ -17,8 +17,31 @@ module Chats
|
|
|
17
17
|
# lives in Chats::Messager / Chats::ChatSubject so it's discoverable,
|
|
18
18
|
# testable, and `include`-able directly when a host prefers that style.
|
|
19
19
|
module Macros
|
|
20
|
-
|
|
20
|
+
# Options (all optional; the defaults are 0.1.x behaviour):
|
|
21
|
+
#
|
|
22
|
+
# notifications: false this messager is never notifiable — a support
|
|
23
|
+
# desk, a bot, an org mailbox. `Participant#
|
|
24
|
+
# notifiable_for?` says no, so hosts stop
|
|
25
|
+
# branching on class in every notifier.
|
|
26
|
+
# blockable: false block/report affordances don't apply to it, so
|
|
27
|
+
# the bundled views hide them.
|
|
28
|
+
# inbox: :grouped every direct conversation with this messager
|
|
29
|
+
# stacks into ONE inbox row (see Chats::Inbox).
|
|
30
|
+
# group_path: ->(viewer) { } where that stacked row links to; defaults
|
|
31
|
+
# to the filtered inbox (`?with=<sgid>`).
|
|
32
|
+
#
|
|
33
|
+
# class Desk < ApplicationRecord
|
|
34
|
+
# acts_as_messager notifications: false, blockable: false, inbox: :grouped
|
|
35
|
+
# end
|
|
36
|
+
def acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil)
|
|
21
37
|
include Chats::Messager
|
|
38
|
+
|
|
39
|
+
self.chat_options = Chats::Messager.normalize_options(
|
|
40
|
+
notifications: notifications,
|
|
41
|
+
blockable: blockable,
|
|
42
|
+
inbox: inbox,
|
|
43
|
+
group_path: group_path
|
|
44
|
+
)
|
|
22
45
|
end
|
|
23
46
|
|
|
24
47
|
def acts_as_chat_subject
|
|
@@ -31,5 +31,28 @@ module Chats
|
|
|
31
31
|
def chat_subject_label
|
|
32
32
|
"#{self.class.model_name.human} #{id}"
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
# Whether conversations ABOUT this record still accept messages. The
|
|
36
|
+
# subject already owns the conversation's meaning, so it owns its
|
|
37
|
+
# openness too — a closed ticket, a delivered order, an archived listing:
|
|
38
|
+
#
|
|
39
|
+
# class Ticket < ApplicationRecord
|
|
40
|
+
# acts_as_chat_subject
|
|
41
|
+
# def chat_locked? = closed?
|
|
42
|
+
# def chat_locked_notice = "This ticket is closed. Reply to reopen it."
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# System messages are exempt (the host can always post "Ticket closed"),
|
|
46
|
+
# and locking is a WRITE rule only: the thread stays readable. See
|
|
47
|
+
# Chats::Conversation#locked?.
|
|
48
|
+
def chat_locked?
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The sentence shown where the composer would be. Nil falls back to the
|
|
53
|
+
# gem's localized "This conversation is closed."
|
|
54
|
+
def chat_locked_notice
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
34
57
|
end
|
|
35
58
|
end
|
|
@@ -16,7 +16,75 @@ module Chats
|
|
|
16
16
|
module Messager
|
|
17
17
|
extend ActiveSupport::Concern
|
|
18
18
|
|
|
19
|
+
# What `acts_as_messager` declares about a messager class. The defaults
|
|
20
|
+
# are exactly 0.1.x behaviour, so a bare `acts_as_messager` is unchanged.
|
|
21
|
+
DEFAULT_CHAT_OPTIONS = {
|
|
22
|
+
notifications: true,
|
|
23
|
+
blockable: true,
|
|
24
|
+
inbox: :default,
|
|
25
|
+
group_path: nil
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
INBOX_MODES = %i[default grouped].freeze
|
|
29
|
+
|
|
30
|
+
# Validate + freeze the macro's options, failing at BOOT with a plain
|
|
31
|
+
# English message rather than at 3am with a NoMethodError.
|
|
32
|
+
def self.normalize_options(notifications:, blockable:, inbox:, group_path:)
|
|
33
|
+
inbox = inbox.to_sym
|
|
34
|
+
unless INBOX_MODES.include?(inbox)
|
|
35
|
+
raise Chats::ConfigurationError,
|
|
36
|
+
"acts_as_messager inbox: must be one of #{INBOX_MODES.inspect}, got #{inbox.inspect}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if group_path && !group_path.respond_to?(:call)
|
|
40
|
+
raise Chats::ConfigurationError,
|
|
41
|
+
"acts_as_messager group_path: must respond to #call (a proc/lambda), got #{group_path.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
notifications: !!notifications,
|
|
46
|
+
blockable: !!blockable,
|
|
47
|
+
inbox: inbox,
|
|
48
|
+
group_path: group_path
|
|
49
|
+
}.freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class_methods do
|
|
53
|
+
# True unless declared with `acts_as_messager notifications: false`.
|
|
54
|
+
def chat_notifications?
|
|
55
|
+
chat_options[:notifications]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# True unless declared with `acts_as_messager blockable: false`.
|
|
59
|
+
def chat_blockable?
|
|
60
|
+
chat_options[:blockable]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# :default | :grouped (see Chats::Inbox).
|
|
64
|
+
def chat_inbox_mode
|
|
65
|
+
chat_options[:inbox]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether every direct conversation with this messager folds into ONE
|
|
69
|
+
# inbox row (see Chats::InboxGroup).
|
|
70
|
+
def chat_grouped_inbox?
|
|
71
|
+
chat_inbox_mode == :grouped
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The `group_path:` callable, or nil (then the stacked row links to the
|
|
75
|
+
# filtered inbox).
|
|
76
|
+
def chat_group_path
|
|
77
|
+
chat_options[:group_path]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
19
81
|
included do
|
|
82
|
+
# Declared by `acts_as_messager`; a plain `include Chats::Messager`
|
|
83
|
+
# gets the defaults. class_attribute so STI subclasses inherit it.
|
|
84
|
+
class_attribute :chat_options,
|
|
85
|
+
instance_accessor: false,
|
|
86
|
+
default: Chats::Messager::DEFAULT_CHAT_OPTIONS
|
|
87
|
+
|
|
20
88
|
has_many :chat_participations,
|
|
21
89
|
class_name: "Chats::Participant",
|
|
22
90
|
as: :messager,
|
|
@@ -63,14 +131,25 @@ module Chats
|
|
|
63
131
|
# alice.message!(bob, "are you coming?")
|
|
64
132
|
# alice.message!(bob, "about the ride", about: ride)
|
|
65
133
|
# alice.message!(conversation, "hi all!", files: [photo])
|
|
66
|
-
|
|
134
|
+
#
|
|
135
|
+
# `author:` is the human (or bot) writing on the SENDER's behalf — an
|
|
136
|
+
# agent answering from a support desk seat. It signs the bubble; the
|
|
137
|
+
# sender stays the conversation identity. See Chats::Message#signed?.
|
|
138
|
+
#
|
|
139
|
+
# desk.message!(alice, "On it!", author: lucia)
|
|
140
|
+
def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil)
|
|
141
|
+
if author && !Chats.messager_class?(author.class)
|
|
142
|
+
raise Chats::NotAllowedError,
|
|
143
|
+
"author must be a messager (acts_as_messager), got #{author.class.name}"
|
|
144
|
+
end
|
|
145
|
+
|
|
67
146
|
conversation =
|
|
68
147
|
case target
|
|
69
148
|
when Chats::Conversation then target
|
|
70
149
|
else chat_with(target, about: about)
|
|
71
150
|
end
|
|
72
151
|
|
|
73
|
-
attributes = { sender: self, body: body, reply_to: reply_to }
|
|
152
|
+
attributes = { sender: self, body: body, reply_to: reply_to, author: author }
|
|
74
153
|
attributes[:files] = files if files.present?
|
|
75
154
|
conversation.messages.create!(**attributes)
|
|
76
155
|
end
|
|
@@ -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).
|
|
@@ -235,6 +245,31 @@ module Chats
|
|
|
235
245
|
subject.try(:chat_subject_label) || "#{subject.class.model_name.human} #{subject.id}"
|
|
236
246
|
end
|
|
237
247
|
|
|
248
|
+
# Whether new messages are refused here, decided by the SUBJECT (see
|
|
249
|
+
# Chats::ChatSubject#chat_locked?). A subjectless conversation is never
|
|
250
|
+
# locked. Reading is never affected — only sending.
|
|
251
|
+
def locked?
|
|
252
|
+
return false if subject.nil?
|
|
253
|
+
|
|
254
|
+
subject.try(:chat_locked?) || false
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The host's explanation for the lock, or the gem's localized fallback.
|
|
258
|
+
# Always a sentence worth showing: a locked composer that says nothing is
|
|
259
|
+
# indistinguishable from a broken one.
|
|
260
|
+
def locked_notice
|
|
261
|
+
return nil unless locked?
|
|
262
|
+
|
|
263
|
+
subject.try(:chat_locked_notice).presence || I18n.t("chats.composer.locked")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The guard every write that ISN'T a message itself calls (reactions
|
|
267
|
+
# today). Message writes go through Chats::Message#refuse_when_locked!,
|
|
268
|
+
# which exempts system messages.
|
|
269
|
+
def refuse_writes_when_locked! # :nodoc:
|
|
270
|
+
raise Chats::LockedError.new(conversation: self) if locked?
|
|
271
|
+
end
|
|
272
|
+
|
|
238
273
|
# --- Membership -----------------------------------------------------------
|
|
239
274
|
|
|
240
275
|
# Idempotent, race-safe membership. Re-adding someone who left re-joins
|
|
@@ -250,6 +285,25 @@ module Chats
|
|
|
250
285
|
participant
|
|
251
286
|
end
|
|
252
287
|
|
|
288
|
+
# Recompute the deterministic identity of a DIRECT thread after its
|
|
289
|
+
# roster changed (see Chats::Participant#reseat!). Without this the key
|
|
290
|
+
# would still name the old pair, and `chat_with` would open a SECOND
|
|
291
|
+
# thread for the new one. No-op for groups (they have no key).
|
|
292
|
+
def reindex_direct_key! # :nodoc:
|
|
293
|
+
return self unless direct?
|
|
294
|
+
|
|
295
|
+
# `reset`: the caller just changed a seat, and a participants
|
|
296
|
+
# association loaded BEFORE that would name the old pair.
|
|
297
|
+
messagers = participants.reset.includes(:messager).filter_map(&:messager)
|
|
298
|
+
return self unless messagers.size == 2
|
|
299
|
+
|
|
300
|
+
update_columns(
|
|
301
|
+
direct_key: self.class.direct_key_for(messagers, subject: subject),
|
|
302
|
+
updated_at: Time.current
|
|
303
|
+
)
|
|
304
|
+
self
|
|
305
|
+
end
|
|
306
|
+
|
|
253
307
|
# --- Messaging ------------------------------------------------------------
|
|
254
308
|
|
|
255
309
|
# 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!
|