chats 0.1.1 → 0.6.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 +1037 -0
- data/README.md +621 -40
- data/app/assets/stylesheets/chats.css +906 -2
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +396 -86
- data/app/controllers/chats/messages_controller.rb +35 -13
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +230 -7
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1120 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +76 -0
- data/app/javascript/chats/split_controller.js +131 -0
- data/app/javascript/chats/thread_controller.js +322 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +61 -0
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
- data/app/views/chats/conversations/_thread.html.erb +147 -0
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +142 -15
- data/app/views/chats/conversations/pane.html.erb +14 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +18 -134
- data/app/views/chats/conversations/window.html.erb +26 -0
- data/app/views/chats/dock/_badge.html.erb +12 -0
- data/app/views/chats/dock/_dock.html.erb +139 -0
- data/app/views/chats/dock/_signal.html.erb +26 -0
- data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
- data/app/views/chats/dock/_window.html.erb +23 -0
- data/app/views/chats/dock/_window_header.html.erb +75 -0
- data/app/views/chats/dock/_window_shell.html.erb +17 -0
- data/app/views/chats/messages/_action.html.erb +15 -0
- data/app/views/chats/messages/_composer.html.erb +84 -56
- data/app/views/chats/messages/_message.html.erb +43 -15
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +10 -1
- data/config/locales/en.yml +40 -0
- data/config/locales/es.yml +40 -0
- data/config/routes.rb +8 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- 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
- data/gemfiles/rails_7.1.gemfile +3 -0
- data/gemfiles/rails_7.2.gemfile +3 -0
- data/gemfiles/rails_8.1.gemfile +3 -0
- data/lib/chats/action.rb +85 -0
- data/lib/chats/broadcasts.rb +178 -17
- data/lib/chats/configuration.rb +115 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +363 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/inbox_refresh_job.rb +14 -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 +122 -6
- data/lib/chats/models/conversation.rb +153 -19
- data/lib/chats/models/message.rb +292 -21
- data/lib/chats/models/participant.rb +67 -0
- data/lib/chats/models/reaction.rb +18 -0
- data/lib/chats/send_rate_limited.rb +30 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +123 -15
- data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
- data/lib/generators/chats/templates/initializer.rb +125 -14
- data/lib/generators/chats/upgrade_generator.rb +79 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +51 -2
|
@@ -10,13 +10,101 @@ module Chats
|
|
|
10
10
|
#
|
|
11
11
|
# alice.message!(bob, "hola!") # DM in one line
|
|
12
12
|
# alice.message!(conversation, "hola!") # or into a conversation
|
|
13
|
+
# alice.message!(bob, "Let's play!", # with a button under it
|
|
14
|
+
# action: { label: "Join my room", url: "/play/abcd" })
|
|
13
15
|
#
|
|
14
16
|
# alice.chats # her inbox, newest first
|
|
15
17
|
# alice.unread_chats_count # for the nav badge
|
|
16
18
|
module Messager
|
|
17
19
|
extend ActiveSupport::Concern
|
|
18
20
|
|
|
21
|
+
# What `acts_as_messager` declares about a messager class. The defaults
|
|
22
|
+
# are exactly 0.1.x behaviour, so a bare `acts_as_messager` is unchanged.
|
|
23
|
+
DEFAULT_CHAT_OPTIONS = {
|
|
24
|
+
notifications: true,
|
|
25
|
+
blockable: true,
|
|
26
|
+
inbox: :default,
|
|
27
|
+
group_path: nil,
|
|
28
|
+
verified: false
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
INBOX_MODES = %i[default grouped].freeze
|
|
32
|
+
|
|
33
|
+
# Validate + freeze the macro's options, failing at BOOT with a plain
|
|
34
|
+
# English message rather than at 3am with a NoMethodError.
|
|
35
|
+
def self.normalize_options(notifications:, blockable:, inbox:, group_path:, verified: false)
|
|
36
|
+
inbox = inbox.to_sym
|
|
37
|
+
unless INBOX_MODES.include?(inbox)
|
|
38
|
+
raise Chats::ConfigurationError,
|
|
39
|
+
"acts_as_messager inbox: must be one of #{INBOX_MODES.inspect}, got #{inbox.inspect}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if group_path && !group_path.respond_to?(:call)
|
|
43
|
+
raise Chats::ConfigurationError,
|
|
44
|
+
"acts_as_messager group_path: must respond to #call (a proc/lambda), got #{group_path.inspect}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Deliberately STRICTER than its boolean neighbours, which coerce with
|
|
48
|
+
# `!!`. "Official account" is a trust claim shown to everyone who talks
|
|
49
|
+
# to this messager, so `verified: "false"` (an ENV var, a YAML
|
|
50
|
+
# round-trip) has to fail at boot rather than quietly verify it.
|
|
51
|
+
unless [true, false].include?(verified)
|
|
52
|
+
raise Chats::ConfigurationError,
|
|
53
|
+
"acts_as_messager verified: must be true or false, got #{verified.inspect}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
notifications: !!notifications,
|
|
58
|
+
blockable: !!blockable,
|
|
59
|
+
inbox: inbox,
|
|
60
|
+
group_path: group_path,
|
|
61
|
+
verified: verified
|
|
62
|
+
}.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class_methods do
|
|
66
|
+
# True unless declared with `acts_as_messager notifications: false`.
|
|
67
|
+
def chat_notifications?
|
|
68
|
+
chat_options[:notifications]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# True unless declared with `acts_as_messager blockable: false`.
|
|
72
|
+
def chat_blockable?
|
|
73
|
+
chat_options[:blockable]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# :default | :grouped (see Chats::Inbox).
|
|
77
|
+
def chat_inbox_mode
|
|
78
|
+
chat_options[:inbox]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether every direct conversation with this messager folds into ONE
|
|
82
|
+
# inbox row (see Chats::InboxGroup).
|
|
83
|
+
def chat_grouped_inbox?
|
|
84
|
+
chat_inbox_mode == :grouped
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The `group_path:` callable, or nil (then the stacked row links to the
|
|
88
|
+
# filtered inbox).
|
|
89
|
+
def chat_group_path
|
|
90
|
+
chat_options[:group_path]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# True when declared with `acts_as_messager verified: true` — an
|
|
94
|
+
# OFFICIAL account (a support desk, an organization, a brand). The
|
|
95
|
+
# bundled views badge its name wherever they show it.
|
|
96
|
+
def chat_verified?
|
|
97
|
+
chat_options[:verified]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
19
101
|
included do
|
|
102
|
+
# Declared by `acts_as_messager`; a plain `include Chats::Messager`
|
|
103
|
+
# gets the defaults. class_attribute so STI subclasses inherit it.
|
|
104
|
+
class_attribute :chat_options,
|
|
105
|
+
instance_accessor: false,
|
|
106
|
+
default: Chats::Messager::DEFAULT_CHAT_OPTIONS
|
|
107
|
+
|
|
20
108
|
has_many :chat_participations,
|
|
21
109
|
class_name: "Chats::Participant",
|
|
22
110
|
as: :messager,
|
|
@@ -63,30 +151,58 @@ module Chats
|
|
|
63
151
|
# alice.message!(bob, "are you coming?")
|
|
64
152
|
# alice.message!(bob, "about the ride", about: ride)
|
|
65
153
|
# alice.message!(conversation, "hi all!", files: [photo])
|
|
66
|
-
|
|
154
|
+
#
|
|
155
|
+
# `author:` is the human (or bot) writing on the SENDER's behalf — an
|
|
156
|
+
# agent answering from a support desk seat. It signs the bubble; the
|
|
157
|
+
# sender stays the conversation identity. See Chats::Message#signed?.
|
|
158
|
+
#
|
|
159
|
+
# desk.message!(alice, "On it!", author: lucia)
|
|
160
|
+
#
|
|
161
|
+
# `action:` is the one featured button under the words — a label and a
|
|
162
|
+
# url (see Chats::Action). It rides on a message the body rules already
|
|
163
|
+
# accept; an action alone is not a message.
|
|
164
|
+
#
|
|
165
|
+
# alice.message!(bob, "Let's play!", action: { label: "Join my room", url: room_path(room) })
|
|
166
|
+
def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil, action: nil)
|
|
67
167
|
conversation =
|
|
68
168
|
case target
|
|
69
169
|
when Chats::Conversation then target
|
|
70
170
|
else chat_with(target, about: about)
|
|
71
171
|
end
|
|
72
172
|
|
|
73
|
-
attributes = { sender: self, body: body, reply_to: reply_to }
|
|
173
|
+
attributes = { sender: self, body: body, reply_to: reply_to, author: author, action: action }
|
|
74
174
|
attributes[:files] = files if files.present?
|
|
75
175
|
conversation.messages.create!(**attributes)
|
|
76
176
|
end
|
|
77
177
|
|
|
78
178
|
# The inbox: every active conversation, blocked counterparts hidden,
|
|
79
|
-
# newest activity first. A relation —
|
|
179
|
+
# `config.inbox_scope` applied, newest activity first. A relation —
|
|
180
|
+
# chain `.limit`, `.includes`, etc.
|
|
181
|
+
#
|
|
182
|
+
# THE one definition of "what is in this messager's inbox": the inbox
|
|
183
|
+
# screen, the unread count, the live badge and a host's own queries all
|
|
184
|
+
# read it, so a conversation the scope hides is hidden everywhere. Until
|
|
185
|
+
# 0.4.1 the badge counted through the raw `inbox_for` relation and could
|
|
186
|
+
# light up for a conversation the inbox would never show.
|
|
187
|
+
#
|
|
188
|
+
# It is also the AUTHORIZATION relation (every engine lookup is
|
|
189
|
+
# `viewer.chats.find(id)`), which is why it includes conversations
|
|
190
|
+
# with no messages yet: the thread you just opened must open. The
|
|
191
|
+
# lists narrow it with `Chats::Conversation.with_messages` (see
|
|
192
|
+
# Chats::Inbox); `chats.with_messages` is what your own lists want too.
|
|
80
193
|
def chats
|
|
81
|
-
Chats::Conversation.inbox_for(self)
|
|
194
|
+
relation = Chats::Conversation.inbox_for(self)
|
|
195
|
+
Chats.config.inbox_scope.call(relation, self) || relation
|
|
82
196
|
end
|
|
83
197
|
|
|
84
198
|
# Number of conversations with unread messages (the WhatsApp-style nav
|
|
85
199
|
# badge counts conversations, not messages — 47 unread messages in one
|
|
86
200
|
# thread is still "1 thing to deal with").
|
|
87
201
|
def unread_chats_count
|
|
88
|
-
#
|
|
89
|
-
#
|
|
202
|
+
# No `with_messages` needed to agree with the inbox: `unread_by` joins
|
|
203
|
+
# the messages, so a conversation with no messages can never be
|
|
204
|
+
# counted. reorder(nil): the inbox scope orders by a COALESCE
|
|
205
|
+
# expression, which PostgreSQL rejects inside a COUNT(DISTINCT …).
|
|
90
206
|
chats.unread_by(self).reorder(nil).distinct.count
|
|
91
207
|
end
|
|
92
208
|
|
|
@@ -59,6 +59,41 @@ module Chats
|
|
|
59
59
|
order(Arel.sql("COALESCE(chats_conversations.last_message_at, chats_conversations.created_at) DESC"))
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
# Conversations that have at least one message, system messages and
|
|
63
|
+
# tombstones included (`last_message_id` set). Not "a person wrote here":
|
|
64
|
+
# a conversation holding only a system note, or only a deleted message,
|
|
65
|
+
# has messages. A conversation exists from the moment it is OPENED
|
|
66
|
+
# (`chat_with`, the Message button), and nobody wants an inbox full of
|
|
67
|
+
# the threads they opened and never used — "No messages yet" rows. So
|
|
68
|
+
# every LIST the gem renders (the inbox, its stacks, search, the
|
|
69
|
+
# `?with=` view, the dock's recent list) reads through this scope, while
|
|
70
|
+
# the authorization relation (`Messager#chats`) does NOT: an empty
|
|
71
|
+
# conversation still opens when addressed directly, which is how its
|
|
72
|
+
# first message gets written.
|
|
73
|
+
#
|
|
74
|
+
# Free, because the denormalized pointer already says it: a conversation
|
|
75
|
+
# has a last message exactly when it has messages (the pointer is set on
|
|
76
|
+
# every create and healed on every hard delete; a soft-deleted tombstone
|
|
77
|
+
# is still a message, so a thread whose only message was deleted stays
|
|
78
|
+
# listed, like everywhere else).
|
|
79
|
+
scope :with_messages, -> { where.not(last_message_id: nil) }
|
|
80
|
+
|
|
81
|
+
# Conversations a messager wrote in, and their complement. A narrower
|
|
82
|
+
# question than `with_messages`, which counts every message: a system
|
|
83
|
+
# line is the app talking ("Your ride was cancelled", which the inbox
|
|
84
|
+
# rightly lists), and a tombstone no longer says anything. These count
|
|
85
|
+
# only Chats::Message.human, what is left when both are set aside. For
|
|
86
|
+
# the readers that must not count a thread holding only "Alice joined":
|
|
87
|
+
# ops walls, moderation lists, analytics.
|
|
88
|
+
#
|
|
89
|
+
# One correlated EXISTS / NOT EXISTS, so they compose with any other
|
|
90
|
+
# scope (`alice.chats`, `inbox_for`, `about`, `unread_by`,
|
|
91
|
+
# `with_messages`) and never multiply rows:
|
|
92
|
+
# alice.chats.with_human_messages
|
|
93
|
+
# Chats::Conversation.about(ride).without_human_messages.count # admin-wide
|
|
94
|
+
scope :with_human_messages, -> { where(human_messages_exist) }
|
|
95
|
+
scope :without_human_messages, -> { where.not(human_messages_exist) }
|
|
96
|
+
|
|
62
97
|
# A messager's inbox: every conversation they're an active participant of
|
|
63
98
|
# (not left), minus direct threads with someone they're blocked with,
|
|
64
99
|
# newest activity first. THE hot query — keep it composable and indexed.
|
|
@@ -114,6 +149,8 @@ module Chats
|
|
|
114
149
|
# mechanism. https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-create_or_find_by
|
|
115
150
|
validate :groups_must_be_enabled, if: :group?
|
|
116
151
|
|
|
152
|
+
after_create_commit -> { Chats.notify(:conversation_created, conversation: self) }
|
|
153
|
+
|
|
117
154
|
# --- Finding & creating ---------------------------------------------------
|
|
118
155
|
|
|
119
156
|
class << self
|
|
@@ -133,16 +170,17 @@ module Chats
|
|
|
133
170
|
raise Chats::BlockedError, "messagers are blocked" if Chats.blocked_between?(a, b)
|
|
134
171
|
raise Chats::NotAllowedError, "policy forbids messaging" unless Chats.can_message?(a, b)
|
|
135
172
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
173
|
+
transaction do
|
|
174
|
+
conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c|
|
|
175
|
+
c.kind = "direct"
|
|
176
|
+
c.subject = about
|
|
177
|
+
end
|
|
178
|
+
# Keep the roster in the creation transaction: the after-commit
|
|
179
|
+
# event must describe a complete conversation, including under an
|
|
180
|
+
# outer host transaction or a failed participant insertion.
|
|
181
|
+
[a, b].each { |messager| conversation.add_participant!(messager) }
|
|
182
|
+
conversation
|
|
139
183
|
end
|
|
140
|
-
|
|
141
|
-
# `create_or_find_by!` may have FOUND a conversation created a moment
|
|
142
|
-
# ago by the other side — participants are ensured idempotently
|
|
143
|
-
# either way (their own unique index makes this race-safe too).
|
|
144
|
-
[a, b].each { |messager| conversation.add_participant!(messager) }
|
|
145
|
-
conversation
|
|
146
184
|
end
|
|
147
185
|
|
|
148
186
|
# Create a group conversation. +others+ excludes the creator (who joins
|
|
@@ -159,10 +197,10 @@ module Chats
|
|
|
159
197
|
raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
|
|
160
198
|
|
|
161
199
|
transaction do
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
others.each { |messager|
|
|
165
|
-
|
|
200
|
+
created = create!(kind: "group", title: title, subject: about)
|
|
201
|
+
created.add_participant!(creator, role: "owner")
|
|
202
|
+
others.each { |messager| created.add_participant!(messager) }
|
|
203
|
+
created
|
|
166
204
|
end
|
|
167
205
|
end
|
|
168
206
|
|
|
@@ -187,6 +225,20 @@ module Chats
|
|
|
187
225
|
.group("chats_conversations.id")
|
|
188
226
|
.count("chats_messages.id")
|
|
189
227
|
end
|
|
228
|
+
|
|
229
|
+
# EXISTS (SELECT 1 FROM chats_messages WHERE conversation_id =
|
|
230
|
+
# chats_conversations.id AND kind = 'text' AND deleted_at IS NULL),
|
|
231
|
+
# built from Chats::Message.human so the two definitions can never
|
|
232
|
+
# drift, and unscoped so a Message scope the caller happens to be inside
|
|
233
|
+
# never leaks in. Public only because scope bodies run on the relation,
|
|
234
|
+
# which cannot reach a private class method.
|
|
235
|
+
def human_messages_exist # :nodoc:
|
|
236
|
+
messages = Chats::Message.arel_table
|
|
237
|
+
Chats::Message.unscoped.human
|
|
238
|
+
.where(messages[:conversation_id].eq(arel_table[:id]))
|
|
239
|
+
.select(Arel.sql("1"))
|
|
240
|
+
.arel.exists
|
|
241
|
+
end
|
|
190
242
|
end
|
|
191
243
|
|
|
192
244
|
# --- Predicates & lookups -------------------------------------------------
|
|
@@ -212,13 +264,30 @@ module Chats
|
|
|
212
264
|
)
|
|
213
265
|
end
|
|
214
266
|
|
|
267
|
+
# The OTHER messager in a direct thread, from +viewer+'s seat — nil for a
|
|
268
|
+
# group, and nil for a direct thread whose other seat has left. The one
|
|
269
|
+
# place the counterpart is resolved, so the title, the avatar and the
|
|
270
|
+
# verified badge on an inbox row always name the same person.
|
|
271
|
+
#
|
|
272
|
+
# Memoized per viewer: an inbox row asks for it two or three times, and a
|
|
273
|
+
# row that cost one query in 0.2.0 must not start costing three.
|
|
274
|
+
def counterpart_for(viewer)
|
|
275
|
+
return nil unless direct?
|
|
276
|
+
|
|
277
|
+
@counterparts ||= {}
|
|
278
|
+
key = viewer && Chats.messager_key(viewer)
|
|
279
|
+
return @counterparts[key] if @counterparts.key?(key)
|
|
280
|
+
|
|
281
|
+
@counterparts[key] = other_participants(viewer).includes(:messager).first&.messager
|
|
282
|
+
end
|
|
283
|
+
|
|
215
284
|
# What this conversation is called from +viewer+'s seat: a direct thread
|
|
216
285
|
# is named after the counterpart; a group after its title (or its
|
|
217
286
|
# members, when untitled).
|
|
218
287
|
def title_for(viewer)
|
|
219
288
|
if direct?
|
|
220
|
-
other =
|
|
221
|
-
other ? Chats.display_name_for(other
|
|
289
|
+
other = counterpart_for(viewer)
|
|
290
|
+
other ? Chats.display_name_for(other) : I18n.t("chats.conversation.empty_title")
|
|
222
291
|
else
|
|
223
292
|
title.presence || participants.active.includes(:messager).limit(4).map do |p|
|
|
224
293
|
Chats.display_name_for(p.messager)
|
|
@@ -235,6 +304,31 @@ module Chats
|
|
|
235
304
|
subject.try(:chat_subject_label) || "#{subject.class.model_name.human} #{subject.id}"
|
|
236
305
|
end
|
|
237
306
|
|
|
307
|
+
# Whether new messages are refused here, decided by the SUBJECT (see
|
|
308
|
+
# Chats::ChatSubject#chat_locked?). A subjectless conversation is never
|
|
309
|
+
# locked. Reading is never affected — only sending.
|
|
310
|
+
def locked?
|
|
311
|
+
return false if subject.nil?
|
|
312
|
+
|
|
313
|
+
subject.try(:chat_locked?) || false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# The host's explanation for the lock, or the gem's localized fallback.
|
|
317
|
+
# Always a sentence worth showing: a locked composer that says nothing is
|
|
318
|
+
# indistinguishable from a broken one.
|
|
319
|
+
def locked_notice
|
|
320
|
+
return nil unless locked?
|
|
321
|
+
|
|
322
|
+
subject.try(:chat_locked_notice).presence || I18n.t("chats.composer.locked")
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# The guard every write that ISN'T a message itself calls (reactions
|
|
326
|
+
# today). Message writes go through Chats::Message#refuse_when_locked!,
|
|
327
|
+
# which exempts system messages.
|
|
328
|
+
def refuse_writes_when_locked! # :nodoc:
|
|
329
|
+
raise Chats::LockedError.new(conversation: self) if locked?
|
|
330
|
+
end
|
|
331
|
+
|
|
238
332
|
# --- Membership -----------------------------------------------------------
|
|
239
333
|
|
|
240
334
|
# Idempotent, race-safe membership. Re-adding someone who left re-joins
|
|
@@ -250,14 +344,46 @@ module Chats
|
|
|
250
344
|
participant
|
|
251
345
|
end
|
|
252
346
|
|
|
347
|
+
# Recompute the deterministic identity of a DIRECT thread after its
|
|
348
|
+
# roster changed (see Chats::Participant#reseat!). Without this the key
|
|
349
|
+
# would still name the old pair, and `chat_with` would open a SECOND
|
|
350
|
+
# thread for the new one. No-op for groups (they have no key).
|
|
351
|
+
def reindex_direct_key! # :nodoc:
|
|
352
|
+
return self unless direct?
|
|
353
|
+
|
|
354
|
+
# `reset`: the caller just changed a seat, and a participants
|
|
355
|
+
# association loaded BEFORE that would name the old pair.
|
|
356
|
+
messagers = participants.reset.includes(:messager).filter_map(&:messager)
|
|
357
|
+
return self unless messagers.size == 2
|
|
358
|
+
|
|
359
|
+
update_columns(
|
|
360
|
+
direct_key: self.class.direct_key_for(messagers, subject: subject),
|
|
361
|
+
updated_at: Time.current
|
|
362
|
+
)
|
|
363
|
+
self
|
|
364
|
+
end
|
|
365
|
+
|
|
253
366
|
# --- Messaging ------------------------------------------------------------
|
|
254
367
|
|
|
255
368
|
# Post a message from your APP into the conversation — "Your ride was
|
|
256
369
|
# cancelled", "Payment received" — rendered as a centered system note,
|
|
257
370
|
# not a bubble. This is the stable entry point notification systems
|
|
258
371
|
# (e.g. a Noticed delivery method) should call.
|
|
259
|
-
|
|
260
|
-
|
|
372
|
+
#
|
|
373
|
+
# `action:` puts a button under the note (see Chats::Action):
|
|
374
|
+
#
|
|
375
|
+
# conversation.post_system_message!("Your ride was cancelled",
|
|
376
|
+
# action: { label: "Book another", url: "/rides/new" })
|
|
377
|
+
def post_system_message!(body, action: nil)
|
|
378
|
+
messages.create!(kind: "system", body: body, action: action)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# The newest thing a messager wrote here (see `.with_human_messages`), or
|
|
382
|
+
# nil when none did. Unlike +last_message+ it skips system lines and
|
|
383
|
+
# tombstones, and it is a query, not the denormalized pointer: one per
|
|
384
|
+
# call. For a list, batch it with Chats::Message.last_human_in(conversations).
|
|
385
|
+
def last_human_message
|
|
386
|
+
messages.human.recent_first.first
|
|
261
387
|
end
|
|
262
388
|
|
|
263
389
|
# --- Read state -----------------------------------------------------------
|
|
@@ -323,11 +449,19 @@ module Chats
|
|
|
323
449
|
field.to_s == "title" && title.present?
|
|
324
450
|
end
|
|
325
451
|
|
|
452
|
+
# Whether anything was removed (see Chats::Message#remove_reported_field!):
|
|
453
|
+
# false for a title already gone, decided under the row lock.
|
|
326
454
|
def remove_reported_field!(field)
|
|
327
455
|
return false unless field.to_s == "title"
|
|
328
456
|
|
|
329
|
-
|
|
330
|
-
|
|
457
|
+
with_lock do
|
|
458
|
+
next false if title.blank?
|
|
459
|
+
|
|
460
|
+
update!(title: nil)
|
|
461
|
+
true
|
|
462
|
+
end
|
|
463
|
+
rescue ActiveRecord::RecordNotFound
|
|
464
|
+
false
|
|
331
465
|
end
|
|
332
466
|
|
|
333
467
|
def report_visible_to?(viewer, field: nil)
|