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
data/lib/chats/models/message.rb
CHANGED
|
@@ -17,6 +17,12 @@ module Chats
|
|
|
17
17
|
# content AT REPORT TIME, so removing the body later never destroys the
|
|
18
18
|
# evidence a moderator needs.
|
|
19
19
|
#
|
|
20
|
+
# == Actions
|
|
21
|
+
#
|
|
22
|
+
# A message may carry ONE featured button — a label and a url, set by the
|
|
23
|
+
# host from Ruby ("Your invoice is ready" → *Pay now*). See Chats::Action
|
|
24
|
+
# for the value and #action_must_be_well_formed for what a safe one is.
|
|
25
|
+
#
|
|
20
26
|
# == Real-time
|
|
21
27
|
#
|
|
22
28
|
# All Turbo Stream fan-out lives in Chats::Broadcasts (one place to read
|
|
@@ -28,11 +34,26 @@ module Chats
|
|
|
28
34
|
|
|
29
35
|
KINDS = %w[text system].freeze
|
|
30
36
|
|
|
37
|
+
# The shape of a renderable action (see #action_must_be_well_formed): a
|
|
38
|
+
# label short enough to be a button, a url short enough to be a link,
|
|
39
|
+
# and only the schemes a browser navigates to safely.
|
|
40
|
+
ACTION_LABEL_LIMIT = 80
|
|
41
|
+
ACTION_URL_LIMIT = 2048
|
|
42
|
+
ACTION_URL_SCHEMES = %w[http https].freeze
|
|
43
|
+
# An authority ending in ":" with nothing after it ("https://host:/x").
|
|
44
|
+
EMPTY_PORT = %r{\A[a-z][a-z0-9+.-]*://[^/?#]*:(?:[/?#]|\z)}i
|
|
45
|
+
ACTION_URL_PORTS = (1..65_535)
|
|
46
|
+
|
|
31
47
|
belongs_to :conversation,
|
|
32
48
|
class_name: "Chats::Conversation",
|
|
33
49
|
inverse_of: :messages,
|
|
34
50
|
counter_cache: :messages_count
|
|
35
51
|
belongs_to :sender, polymorphic: true, optional: true
|
|
52
|
+
# The person (or bot) who WROTE this on the sender's behalf — an agent
|
|
53
|
+
# answering from a shared support-desk seat. The sender stays the
|
|
54
|
+
# conversation identity ("Soporte CarHey"); the author signs the bubble
|
|
55
|
+
# ("— Lucía G."). Optional, and nil for every ordinary message.
|
|
56
|
+
belongs_to :author, polymorphic: true, optional: true
|
|
36
57
|
belongs_to :reply_to, class_name: "Chats::Message", optional: true
|
|
37
58
|
|
|
38
59
|
has_many :reactions,
|
|
@@ -51,10 +72,86 @@ module Chats
|
|
|
51
72
|
# see the migration template's json_column_default).
|
|
52
73
|
attribute :metadata, default: -> { {} }
|
|
53
74
|
|
|
75
|
+
# The featured button (Chats::Action), or nil. Its own column rather than
|
|
76
|
+
# a key in `metadata`, which stays the host's bag: an action is a
|
|
77
|
+
# contract the bundled bubble renders, so the gem must own where it lives.
|
|
78
|
+
attribute :action, Chats::Action::Type.new
|
|
79
|
+
|
|
54
80
|
scope :recent_first, -> { order(created_at: :desc, id: :desc) }
|
|
55
81
|
scope :oldest_first, -> { order(created_at: :asc, id: :asc) }
|
|
56
82
|
scope :visible, -> { where(deleted_at: nil) }
|
|
57
83
|
|
|
84
|
+
# What messagers wrote: text messages that still stand. "Human" is the
|
|
85
|
+
# gem's word for a text message, written by a messager, person or not
|
|
86
|
+
# (a bot's or a headless desk's message counts), as opposed to a system
|
|
87
|
+
# line ("Alice joined", "Your ride was cancelled"), which is the app
|
|
88
|
+
# talking. A tombstone no longer says anything. A photo with no caption
|
|
89
|
+
# is still a text message and still counts. The inbox does NOT read through this,
|
|
90
|
+
# on purpose — see README "Real conversations vs system-only threads".
|
|
91
|
+
scope :human, -> { where(kind: "text").visible }
|
|
92
|
+
|
|
93
|
+
# The newest human message of each given conversation, at most one row
|
|
94
|
+
# per conversation, in ONE query: the batch twin of
|
|
95
|
+
# Conversation#last_human_message for lists (admin walls, moderation
|
|
96
|
+
# queues). +conversations+ is a page of records, ids, a single record, or
|
|
97
|
+
# a Chats::Conversation relation — an unloaded relation is read as a
|
|
98
|
+
# subquery, never loaded first, and without its `includes`/`preload`/
|
|
99
|
+
# `eager_load` (they load records; a subquery has none to load). So a
|
|
100
|
+
# relation that FILTERS on an associated table must join it explicitly
|
|
101
|
+
# (`joins` / `left_joins`): `includes(:x).references(:x).where(x: …)`
|
|
102
|
+
# loses the join and raises. Or pass the loaded page, or its ids.
|
|
103
|
+
#
|
|
104
|
+
# The result is a relation, so the columns can be narrowed:
|
|
105
|
+
#
|
|
106
|
+
# Chats::Message.last_human_in(conversations)
|
|
107
|
+
# .select(:id, :conversation_id, :created_at)
|
|
108
|
+
# .index_by(&:conversation_id) # { id => message }
|
|
109
|
+
#
|
|
110
|
+
# A conversation nobody wrote in has no row. ROW_NUMBER works on every
|
|
111
|
+
# supported adapter (PostgreSQL, SQLite and MySQL 8), the same ranking
|
|
112
|
+
# Chats::Inbox uses for its stacks. The ranked subquery is built unscoped,
|
|
113
|
+
# so neither a scope it is called on nor a `.select` chained onto the
|
|
114
|
+
# result leaks into it.
|
|
115
|
+
scope :last_human_in, lambda { |conversations|
|
|
116
|
+
ids = Chats::Message.conversation_ids_in(conversations)
|
|
117
|
+
next none if ids.is_a?(Array) && ids.empty?
|
|
118
|
+
|
|
119
|
+
ranking = "ROW_NUMBER() OVER (PARTITION BY chats_messages.conversation_id " \
|
|
120
|
+
"ORDER BY chats_messages.created_at DESC, chats_messages.id DESC) AS human_rank"
|
|
121
|
+
ranked = Chats::Message.unscoped.human.where(conversation_id: ids)
|
|
122
|
+
.select(Arel.sql("chats_messages.id"), Arel.sql(ranking))
|
|
123
|
+
newest = Chats::Message.unscoped.from(ranked, :ranked_messages)
|
|
124
|
+
.where(Arel.sql("ranked_messages.human_rank = 1"))
|
|
125
|
+
.select(Arel.sql("ranked_messages.id"))
|
|
126
|
+
where(id: newest)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# The conversation ids +last_human_in+ ranks within: an Array for records
|
|
130
|
+
# and ids (a loaded relation included, read from memory), a subquery for
|
|
131
|
+
# an unloaded relation, as a derived table because MySQL rejects LIMIT
|
|
132
|
+
# directly inside IN, and a page is usually limited.
|
|
133
|
+
#
|
|
134
|
+
# A LIMITed or OFFSET page is its own query, word for word (its select
|
|
135
|
+
# list, DISTINCT and ORDER BY decide WHICH rows it holds, and PostgreSQL
|
|
136
|
+
# refuses a DISTINCT whose ORDER BY left the select list), so it must
|
|
137
|
+
# expose an `id` column, as the default select list does. Any other
|
|
138
|
+
# relation is just a set of ids: narrowed to `id`, unordered (the inbox's
|
|
139
|
+
# ORDER BY COALESCE(…) next to DISTINCT id is another refusal).
|
|
140
|
+
def self.conversation_ids_in(conversations) # :nodoc:
|
|
141
|
+
unless conversations.is_a?(ActiveRecord::Relation)
|
|
142
|
+
return Array(conversations).map { |c| c.is_a?(Chats::Conversation) ? c.id : c }
|
|
143
|
+
end
|
|
144
|
+
unless conversations.klass <= Chats::Conversation
|
|
145
|
+
raise ArgumentError, "expected Chats::Conversation records, ids or a relation of them, " \
|
|
146
|
+
"got a #{conversations.klass.name} relation"
|
|
147
|
+
end
|
|
148
|
+
return conversations.map(&:id) if conversations.loaded?
|
|
149
|
+
|
|
150
|
+
page = conversations.except(:includes, :preload, :eager_load)
|
|
151
|
+
page = page.reselect(page.arel_table[:id]).reorder(nil) unless page.limit_value || page.offset_value
|
|
152
|
+
Chats::Conversation.unscoped.from(page, :conversation_page).select(Arel.sql("conversation_page.id"))
|
|
153
|
+
end
|
|
154
|
+
|
|
58
155
|
# Keyset (cursor) pagination for infinite scroll-up. OFFSET pagination
|
|
59
156
|
# drifts when new messages arrive mid-scroll; anchoring on the oldest
|
|
60
157
|
# loaded message can't skip or duplicate. `(created_at, id)` because
|
|
@@ -67,17 +164,35 @@ module Chats
|
|
|
67
164
|
}
|
|
68
165
|
|
|
69
166
|
# Catch-up scopes for ConversationsController#refresh (the stale-thread
|
|
70
|
-
# recovery path — see the thread controller's
|
|
71
|
-
# is
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
167
|
+
# recovery path — see the thread controller's catch-up), on the client's
|
|
168
|
+
# two cursors. Each is a moment, to the microsecond, up to which the
|
|
169
|
+
# client KNOWS it has everything (a complete render, or a catch-up that
|
|
170
|
+
# succeeded — never a live bubble, which can arrive past one it missed),
|
|
171
|
+
# with the ids it has on screen from that moment on:
|
|
172
|
+
# created_after(at, seen:) — created at or after that moment and not
|
|
173
|
+
# on screen: appended;
|
|
174
|
+
# created_through(at, seen:) — the complement: what the client has;
|
|
175
|
+
# updated_after(at, seen:) — changed after that moment, or at it and
|
|
176
|
+
# not among the ones seen in that version.
|
|
177
|
+
# Replacements are created_through(after).updated_after(changed): changes
|
|
178
|
+
# to what is on screen, never a second copy of what is appended — a Turbo
|
|
179
|
+
# append of an existing dom_id would MOVE that bubble to the end. Two
|
|
180
|
+
# cursors, because one can't answer both: a message created at 10:15 and
|
|
181
|
+
# missed is older than a 10:20 edit that did arrive.
|
|
79
182
|
# Pattern from Basecamp's Campfire (Rooms::RefreshesController):
|
|
80
183
|
# https://github.com/basecamp/once-campfire
|
|
184
|
+
scope :created_after, lambda { |at, seen: []|
|
|
185
|
+
where(arel_table[:created_at].gteq(at)).where.not(id: seen)
|
|
186
|
+
}
|
|
187
|
+
scope :created_through, lambda { |at, seen: []|
|
|
188
|
+
where(arel_table[:created_at].lt(at)).or(where(id: seen))
|
|
189
|
+
}
|
|
190
|
+
scope :updated_after, lambda { |at, seen: []|
|
|
191
|
+
where(arel_table[:updated_at].gt(at)).or(where(updated_at: at).where.not(id: seen))
|
|
192
|
+
}
|
|
193
|
+
# The one-cursor catch-up of bubbles ejected before they carried the
|
|
194
|
+
# cursors above (`data-updated-at-ms` alone, rounded up): what was
|
|
195
|
+
# created after it, and what was rendered by then and changed since.
|
|
81
196
|
scope :created_since, ->(time) { where("chats_messages.created_at > ?", time) }
|
|
82
197
|
scope :updated_since, lambda { |time|
|
|
83
198
|
where("chats_messages.updated_at > ?", time).where("chats_messages.created_at <= ?", time)
|
|
@@ -89,8 +204,15 @@ module Chats
|
|
|
89
204
|
validate :body_or_files_required
|
|
90
205
|
validate :body_must_fit_length_limit
|
|
91
206
|
validate :sender_must_be_active_participant, on: :create
|
|
92
|
-
validate :
|
|
207
|
+
validate :conversation_must_not_be_locked, on: :create
|
|
208
|
+
validate :author_must_be_persisted
|
|
209
|
+
# Checked at the write that SETS it, not on every save: an edit must
|
|
210
|
+
# never fail over an action nobody touched.
|
|
211
|
+
validate :action_must_be_well_formed, if: :will_save_change_to_action?
|
|
93
212
|
validate :files_must_be_allowed
|
|
213
|
+
# Last on purpose: the host's policy is asked only of a send that is
|
|
214
|
+
# otherwise valid (see the method).
|
|
215
|
+
validate :sender_must_still_be_allowed, on: :create
|
|
94
216
|
|
|
95
217
|
after_create :register_on_conversation
|
|
96
218
|
after_create_commit :broadcast_created
|
|
@@ -103,6 +225,14 @@ module Chats
|
|
|
103
225
|
def text? = kind == "text"
|
|
104
226
|
def deleted? = deleted_at.present?
|
|
105
227
|
def edited? = edited_at.present?
|
|
228
|
+
def action? = !action.nil?
|
|
229
|
+
|
|
230
|
+
# Wraps on the way in, not lazily on first read (ActiveModel casts user
|
|
231
|
+
# input when it is read): `message.action = "/pay"` raises right here,
|
|
232
|
+
# at the line that made the mistake.
|
|
233
|
+
def action=(value)
|
|
234
|
+
super(Chats::Action.wrap(value))
|
|
235
|
+
end
|
|
106
236
|
|
|
107
237
|
def sender_key
|
|
108
238
|
sender && Chats.messager_key(sender)
|
|
@@ -112,6 +242,19 @@ module Chats
|
|
|
112
242
|
sender.present? && sender == messager
|
|
113
243
|
end
|
|
114
244
|
|
|
245
|
+
# Written by someone OTHER than the seat it was sent from — the case a
|
|
246
|
+
# signature exists for. A message an author sent from their own seat is
|
|
247
|
+
# not "signed"; it's just theirs.
|
|
248
|
+
def signed?
|
|
249
|
+
author.present? && author != sender
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Whether +messager+ is the one who WROTE this (not necessarily the seat
|
|
253
|
+
# it was sent from).
|
|
254
|
+
def authored_by?(messager)
|
|
255
|
+
author.present? && author == messager
|
|
256
|
+
end
|
|
257
|
+
|
|
115
258
|
# The body as the UI should show it (tombstones render a localized
|
|
116
259
|
# "Message deleted" placeholder straight from the view, not from here —
|
|
117
260
|
# this just guards against showing stale bodies by accident).
|
|
@@ -133,17 +276,28 @@ module Chats
|
|
|
133
276
|
raise Chats::NotAllowedError, "editing is disabled" unless Chats.config.editing
|
|
134
277
|
raise Chats::NotAllowedError, "can't edit a deleted message" if deleted?
|
|
135
278
|
|
|
279
|
+
refuse_when_locked!
|
|
136
280
|
update!(body: new_body, edited_at: Time.current)
|
|
137
281
|
end
|
|
138
282
|
|
|
139
283
|
# Delete according to `config.deletion` (see class comment). Returns
|
|
140
284
|
# false when deletion is disabled.
|
|
141
|
-
|
|
285
|
+
#
|
|
286
|
+
# A tombstone shows neither the words nor the button that came with
|
|
287
|
+
# them, so the soft path clears the action too — and moderation, which
|
|
288
|
+
# removes content through here, takes the link down with the body.
|
|
289
|
+
#
|
|
290
|
+
# `enforce_lock: false` is for MODERATION only (see
|
|
291
|
+
# #remove_reported_field!): a product lock must never shield reported
|
|
292
|
+
# content from removal.
|
|
293
|
+
def soft_delete!(enforce_lock: true)
|
|
294
|
+
refuse_when_locked! if enforce_lock
|
|
295
|
+
|
|
142
296
|
case Chats.config.deletion
|
|
143
297
|
when :soft
|
|
144
298
|
transaction do
|
|
145
299
|
files.purge_later if respond_to?(:files) && files.attached?
|
|
146
|
-
update!(body: nil, deleted_at: Time.current)
|
|
300
|
+
update!(body: nil, action: nil, deleted_at: Time.current)
|
|
147
301
|
end
|
|
148
302
|
true
|
|
149
303
|
when :hard
|
|
@@ -158,6 +312,16 @@ module Chats
|
|
|
158
312
|
respond_to?(:files) && files.attached?
|
|
159
313
|
end
|
|
160
314
|
|
|
315
|
+
# Every WRITE to an existing message goes through here, for the same
|
|
316
|
+
# reason `create` validates the lock: a closed conversation is closed for
|
|
317
|
+
# editing and deleting too, not just for new messages. System messages
|
|
318
|
+
# stay exempt — the app owns them.
|
|
319
|
+
def refuse_when_locked! # :nodoc:
|
|
320
|
+
return if system? || conversation.nil? || !conversation.locked?
|
|
321
|
+
|
|
322
|
+
raise Chats::LockedError.new(conversation: conversation)
|
|
323
|
+
end
|
|
324
|
+
|
|
161
325
|
# --- Moderation contract (duck-typed, zero coupling) ------------------------
|
|
162
326
|
#
|
|
163
327
|
# Plain-Ruby methods that make a message a first-class citizen of the
|
|
@@ -167,8 +331,14 @@ module Chats
|
|
|
167
331
|
# seams for attachment filtering. Without moderate installed they're
|
|
168
332
|
# inert and cost nothing.
|
|
169
333
|
|
|
334
|
+
# Who answers for this message: its AUTHOR when it is signed, else its
|
|
335
|
+
# sender. A signed message was written by a human from a seat that is not
|
|
336
|
+
# a person — an agent answering from a support desk — and the seat cannot
|
|
337
|
+
# be the owner a moderation flag or report points at: the host's `owner`
|
|
338
|
+
# is typed to its user class, so a desk there is a type mismatch raised
|
|
339
|
+
# from inside the agent's own reply the first time a filter trips.
|
|
170
340
|
def reported_owner
|
|
171
|
-
sender
|
|
341
|
+
author || sender
|
|
172
342
|
end
|
|
173
343
|
|
|
174
344
|
def moderation_label
|
|
@@ -186,10 +356,28 @@ module Chats
|
|
|
186
356
|
# A moderator removing a reported message body = the soft-delete
|
|
187
357
|
# tombstone path, so the thread shows "Message deleted" instead of a
|
|
188
358
|
# hole, and attachments are purged with it.
|
|
359
|
+
#
|
|
360
|
+
# Returns whether anything was removed: true only when it actually
|
|
361
|
+
# removed something (the tombstone, or the row under `deletion: :hard`);
|
|
362
|
+
# false for a message already deleted (by its sender, by an earlier
|
|
363
|
+
# decision), already gone, or whose body is already blank. moderate
|
|
364
|
+
# 1.0.0.beta3 and later take that as the truth and refuse a removal that
|
|
365
|
+
# removed nothing, rather than record "content removed" with a statement
|
|
366
|
+
# of reasons that would be false; earlier versions ignore the return
|
|
367
|
+
# value. Decided under the message's row lock, read again under it: two
|
|
368
|
+
# decisions taken at once cannot both see it standing.
|
|
189
369
|
def remove_reported_field!(field)
|
|
190
370
|
return false unless field.to_s == "body"
|
|
191
371
|
|
|
192
|
-
|
|
372
|
+
with_lock do
|
|
373
|
+
next false if deleted? || body.blank?
|
|
374
|
+
|
|
375
|
+
# Trust & Safety outranks a product lock: a closed conversation must
|
|
376
|
+
# never be a place reported content can hide.
|
|
377
|
+
soft_delete!(enforce_lock: false)
|
|
378
|
+
end
|
|
379
|
+
rescue ActiveRecord::RecordNotFound
|
|
380
|
+
false # hard-deleted meanwhile: nothing left to remove
|
|
193
381
|
end
|
|
194
382
|
|
|
195
383
|
# Only people *in* the conversation may report a message (a message
|
|
@@ -256,15 +444,98 @@ module Chats
|
|
|
256
444
|
errors.add(:sender, :not_a_participant) unless conversation.participant?(sender)
|
|
257
445
|
end
|
|
258
446
|
|
|
259
|
-
#
|
|
260
|
-
# block placed mid-conversation
|
|
261
|
-
#
|
|
262
|
-
#
|
|
263
|
-
|
|
264
|
-
|
|
447
|
+
# Who may talk to whom is decided at the WRITE, not just when the
|
|
448
|
+
# conversation was opened: a block placed mid-conversation stops the very
|
|
449
|
+
# next send, and so does the host's `can_message` policy turning false —
|
|
450
|
+
# an account banned, a listing closed, a membership ended. The pair was
|
|
451
|
+
# allowed when the thread began; what matters is whether it still is.
|
|
452
|
+
# Direct threads only — see Conversation.excluding_blocked_for for the
|
|
453
|
+
# group rationale. Two errors, because they read differently to the
|
|
454
|
+
# sender: `:blocked` (either side blocked the other) and `:not_allowed`
|
|
455
|
+
# (the policy said no).
|
|
456
|
+
#
|
|
457
|
+
# Asked last, and only of a send that is otherwise valid: a stranger to
|
|
458
|
+
# the conversation, a locked thread or an empty body is refused before
|
|
459
|
+
# the host's policy runs, so the policy is never consulted about a
|
|
460
|
+
# message that could not be sent anyway (it may be expensive, and it
|
|
461
|
+
# runs on every direct send now). A policy must not raise: an
|
|
462
|
+
# exception escapes the send like any other host error.
|
|
463
|
+
#
|
|
464
|
+
# A direct thread with nobody on the other side — the counterpart's
|
|
465
|
+
# account was destroyed and its seat went with it — takes no messages:
|
|
466
|
+
# there is no one the policy could allow.
|
|
467
|
+
def sender_must_still_be_allowed
|
|
468
|
+
return if system? || sender.nil? || conversation.nil? || !conversation.direct? || errors.any?
|
|
265
469
|
|
|
266
470
|
other = conversation.other_participants(sender).first&.messager
|
|
267
|
-
|
|
471
|
+
if other.nil?
|
|
472
|
+
errors.add(:base, :not_allowed)
|
|
473
|
+
elsif Chats.blocked_between?(sender, other)
|
|
474
|
+
errors.add(:base, :blocked)
|
|
475
|
+
elsif !Chats.config.can_message.call(sender, other)
|
|
476
|
+
errors.add(:base, :not_allowed)
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Authorship is a signature, not a conversation seat. A host may keep
|
|
481
|
+
# staff in a separate model without giving them messaging capabilities.
|
|
482
|
+
# Never implicitly create that identity while sending a message.
|
|
483
|
+
def author_must_be_persisted
|
|
484
|
+
return if author.nil? || author.persisted?
|
|
485
|
+
|
|
486
|
+
errors.add(:author, :invalid)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# What a button the bubble renders may be: a short label, and a link to
|
|
490
|
+
# an http(s) URL or a path on this site. The host is trusted, but a bad
|
|
491
|
+
# action has to fail HERE, loudly, rather than render a dead link or a
|
|
492
|
+
# `javascript:` one.
|
|
493
|
+
#
|
|
494
|
+
# Two layers, because a browser and URI.parse disagree about where a url
|
|
495
|
+
# goes. First, a string check BEFORE parsing: nothing starting with `//`
|
|
496
|
+
# or `/\` is a path on this site. URI.parse reads `///evil.example/x`
|
|
497
|
+
# as a path with no host, yet a browser resolves it to
|
|
498
|
+
# https://evil.example/x, and treats `/\` like `//`. Then the parse,
|
|
499
|
+
# which rejects whitespace, control characters and backslashes anywhere
|
|
500
|
+
# (`/<tab>/evil.example` is stripped into `//evil.example` by browsers),
|
|
501
|
+
# and decides scheme, host and port.
|
|
502
|
+
def action_must_be_well_formed
|
|
503
|
+
return if action.nil?
|
|
504
|
+
|
|
505
|
+
errors.add(:action, :invalid, count: ACTION_LABEL_LIMIT) unless action_label_fits? && action_url_safe?
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def action_label_fits?
|
|
509
|
+
action.label.present? && action.label.length <= ACTION_LABEL_LIMIT
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def action_url_safe?
|
|
513
|
+
url = action.url
|
|
514
|
+
return false if url.blank? || url.length > ACTION_URL_LIMIT
|
|
515
|
+
return false if url.start_with?("//", "/\\")
|
|
516
|
+
# `https://host:` parses as the default port, so an empty port suffix
|
|
517
|
+
# would pass the range check below; a named port must have digits.
|
|
518
|
+
return false if url.match?(EMPTY_PORT)
|
|
519
|
+
|
|
520
|
+
uri = URI.parse(url)
|
|
521
|
+
if uri.scheme
|
|
522
|
+
ACTION_URL_SCHEMES.include?(uri.scheme.downcase) && uri.host.present? && ACTION_URL_PORTS.cover?(uri.port)
|
|
523
|
+
else
|
|
524
|
+
# A path on this site: "/rides/new", never "//elsewhere.example".
|
|
525
|
+
url.start_with?("/") && uri.host.nil?
|
|
526
|
+
end
|
|
527
|
+
rescue URI::InvalidURIError
|
|
528
|
+
false
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# The subject owns the conversation's openness (Chats::ChatSubject#
|
|
532
|
+
# chat_locked?). System messages are exempt: the host must always be able
|
|
533
|
+
# to post "This ticket was closed" into the thread it just closed.
|
|
534
|
+
def conversation_must_not_be_locked
|
|
535
|
+
return if system? || conversation.nil?
|
|
536
|
+
return unless conversation.locked?
|
|
537
|
+
|
|
538
|
+
errors.add(:base, :locked)
|
|
268
539
|
end
|
|
269
540
|
|
|
270
541
|
def files_must_be_allowed
|
|
@@ -43,6 +43,8 @@ module Chats
|
|
|
43
43
|
# concurrent joins race-safe. Same rationale as Conversation#direct_key.
|
|
44
44
|
validate :group_must_have_room, on: :create
|
|
45
45
|
|
|
46
|
+
after_update_commit :notify_departure, if: -> { saved_change_to_left_at? && left? }
|
|
47
|
+
|
|
46
48
|
def owner? = role == "owner"
|
|
47
49
|
def left? = left_at.present?
|
|
48
50
|
def active? = left_at.nil?
|
|
@@ -104,7 +106,44 @@ module Chats
|
|
|
104
106
|
def unmute! = update!(muted_at: nil)
|
|
105
107
|
|
|
106
108
|
def leave!
|
|
109
|
+
return self if left?
|
|
110
|
+
|
|
107
111
|
update!(left_at: Time.current)
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def notify_departure
|
|
116
|
+
Chats.notify(:participant_left, participant: self)
|
|
117
|
+
end
|
|
118
|
+
private :notify_departure
|
|
119
|
+
|
|
120
|
+
# Hand this seat to a different messager, keeping the read horizon, the
|
|
121
|
+
# role and the history: the guest who signs up, the agent who takes over
|
|
122
|
+
# a shared mailbox. The MESSAGES keep their original sender — what was
|
|
123
|
+
# said was said by whoever said it.
|
|
124
|
+
#
|
|
125
|
+
# Direct conversations have their +direct_key+ recomputed, so the thread
|
|
126
|
+
# keeps resolving through `chat_with` for the NEW pair instead of
|
|
127
|
+
# stranding a duplicate.
|
|
128
|
+
def reseat!(new_messager)
|
|
129
|
+
raise ArgumentError, "reseat! requires a messager" if new_messager.nil?
|
|
130
|
+
unless Chats.messager_class?(new_messager.class)
|
|
131
|
+
raise Chats::NotAllowedError, "#{new_messager.class.name} is not a messager (acts_as_messager)"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
refuse_reseat_conflicts!(new_messager)
|
|
135
|
+
|
|
136
|
+
transaction do
|
|
137
|
+
update!(messager: new_messager)
|
|
138
|
+
conversation.reindex_direct_key!
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
self
|
|
142
|
+
rescue ActiveRecord::RecordNotUnique
|
|
143
|
+
# The race backstop for the checks above (two reseats, or a DM opened,
|
|
144
|
+
# between the check and the write). Translated so a host never has a
|
|
145
|
+
# driver-level exception poison its transaction.
|
|
146
|
+
raise Chats::NotAllowedError, "that conversation already exists for the new pair"
|
|
108
147
|
end
|
|
109
148
|
|
|
110
149
|
# --- Notification etiquette (for host notifier hooks) ----------------------
|
|
@@ -114,6 +153,10 @@ module Chats
|
|
|
114
153
|
# re-derive it: don't notify yourself, the muted, the departed — and for
|
|
115
154
|
# debounced email digests, don't notify twice for the same unread burst.
|
|
116
155
|
def notifiable_for?(message)
|
|
156
|
+
# Headless messagers (`acts_as_messager notifications: false`) — a
|
|
157
|
+
# support desk, a bot, an org mailbox — are never notifiable. This is
|
|
158
|
+
# THE reason hosts no longer branch on class in their notifiers.
|
|
159
|
+
return false unless Chats.notifications_for?(messager)
|
|
117
160
|
return false if left? || muted?
|
|
118
161
|
return false if message.sender == messager
|
|
119
162
|
|
|
@@ -135,6 +178,30 @@ module Chats
|
|
|
135
178
|
|
|
136
179
|
private
|
|
137
180
|
|
|
181
|
+
# Everything that would make the reseat collide, checked BEFORE the
|
|
182
|
+
# write. A unique-index violation inside the transaction would abort the
|
|
183
|
+
# host's transaction too on PostgreSQL, so the pre-check is the real
|
|
184
|
+
# guard and the RecordNotUnique rescue is only the race backstop.
|
|
185
|
+
def refuse_reseat_conflicts!(new_messager)
|
|
186
|
+
if conversation.participants.where.not(id: id).exists?(
|
|
187
|
+
messager_type: new_messager.class.polymorphic_name, messager_id: new_messager.id
|
|
188
|
+
)
|
|
189
|
+
raise Chats::NotAllowedError, "that messager already has a seat in this conversation"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
return unless conversation.direct?
|
|
193
|
+
|
|
194
|
+
other = conversation.other_participants(messager).includes(:messager).first&.messager
|
|
195
|
+
return if other.nil?
|
|
196
|
+
|
|
197
|
+
existing = Chats::Conversation.direct_between(new_messager, other, about: conversation.subject)
|
|
198
|
+
return if existing.nil? || existing == conversation
|
|
199
|
+
|
|
200
|
+
raise Chats::NotAllowedError,
|
|
201
|
+
"#{Chats.display_name_for(new_messager)} already has a direct conversation with " \
|
|
202
|
+
"#{Chats.display_name_for(other)}"
|
|
203
|
+
end
|
|
204
|
+
|
|
138
205
|
def group_must_have_room
|
|
139
206
|
return if conversation.nil? || conversation.direct?
|
|
140
207
|
|
|
@@ -27,11 +27,25 @@ module Chats
|
|
|
27
27
|
# Rails behavior: https://guides.rubyonrails.org/active_record_callbacks.html#using-both-after-create-commit-and-after-update-commit
|
|
28
28
|
after_commit :broadcast_change, on: %i[create destroy]
|
|
29
29
|
|
|
30
|
+
# A reaction changes how its message renders, so it moves the message's
|
|
31
|
+
# updated_at: that is what a thread catching up on missed broadcasts asks
|
|
32
|
+
# for (Message.updated_after). update_columns, not touch: the
|
|
33
|
+
# broadcast above already re-renders the bubble, and touch would run the
|
|
34
|
+
# message's own update callbacks and broadcast it a second time. Not when
|
|
35
|
+
# the message itself is being destroyed and takes its reactions with it.
|
|
36
|
+
after_create :stamp_message
|
|
37
|
+
after_destroy :stamp_message, unless: :destroyed_by_association
|
|
38
|
+
|
|
30
39
|
# Add the reaction if absent, remove it if present (the universal
|
|
31
40
|
# tap-to-toggle semantic). Returns the created reaction, or false when
|
|
32
41
|
# toggled off. Race-safe: a concurrent double-tap resolves through the
|
|
33
42
|
# unique index instead of raising.
|
|
34
43
|
def self.toggle!(message:, reactor:, emoji:)
|
|
44
|
+
# Reacting is a write to the conversation, so a locked one refuses it —
|
|
45
|
+
# in BOTH directions: you can't add a reaction to a closed thread, and
|
|
46
|
+
# you can't take one back either. Same rule as editing and deleting.
|
|
47
|
+
message&.conversation&.refuse_writes_when_locked!
|
|
48
|
+
|
|
35
49
|
existing = find_by(message: message, reactor: reactor, emoji: emoji)
|
|
36
50
|
if existing
|
|
37
51
|
existing.destroy!
|
|
@@ -63,6 +77,10 @@ module Chats
|
|
|
63
77
|
errors.add(:reactor, :not_a_participant) unless message.conversation.participant?(reactor)
|
|
64
78
|
end
|
|
65
79
|
|
|
80
|
+
def stamp_message
|
|
81
|
+
message.update_columns(updated_at: Time.current)
|
|
82
|
+
end
|
|
83
|
+
|
|
66
84
|
def broadcast_change
|
|
67
85
|
Chats::Broadcasts.message_updated(message) unless message.destroyed?
|
|
68
86
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# Share one sender budget across chat and product-specific HTTP composers.
|
|
5
|
+
# The host owns the cache store. Using its atomic increment also works on
|
|
6
|
+
# Rails 7 and avoids depending on Rails' private controller limiter API.
|
|
7
|
+
module SendRateLimited
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
before_action :enforce_chat_send_rate_limit, only: :create
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def chat_rate_limit_messager
|
|
17
|
+
send(Chats.config.current_messager_method)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enforce_chat_send_rate_limit
|
|
21
|
+
limit = Chats.config.send_rate_limit
|
|
22
|
+
return unless limit
|
|
23
|
+
|
|
24
|
+
sender = chat_rate_limit_messager&.to_gid&.to_s || request.remote_ip
|
|
25
|
+
count = self.class.cache_store.increment("rate-limit:chats/messages:#{sender}", 1,
|
|
26
|
+
expires_in: limit.fetch(:within))
|
|
27
|
+
head :too_many_requests if count && count > limit.fetch(:to)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|