chats 0.1.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 +7 -0
- data/.rubocop.yml +43 -0
- data/.simplecov +52 -0
- data/AGENTS.md +5 -0
- data/Appraisals +17 -0
- data/CHANGELOG.md +74 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/chats.css +818 -0
- data/app/controllers/chats/application_controller.rb +65 -0
- data/app/controllers/chats/conversations_controller.rb +198 -0
- data/app/controllers/chats/messages_controller.rb +118 -0
- data/app/controllers/chats/reactions_controller.rb +33 -0
- data/app/helpers/chats/engine_helper.rb +212 -0
- data/app/javascript/chats/composer_controller.js +258 -0
- data/app/javascript/chats/debounced_submit_controller.js +40 -0
- data/app/javascript/chats/thread_controller.js +855 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
- data/app/views/chats/conversations/_messages_page.html.erb +16 -0
- data/app/views/chats/conversations/_read_state.html.erb +11 -0
- data/app/views/chats/conversations/index.html.erb +54 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
- data/app/views/chats/conversations/show.html.erb +137 -0
- data/app/views/chats/messages/_composer.html.erb +67 -0
- data/app/views/chats/messages/_message.html.erb +158 -0
- data/app/views/chats/messages/create.turbo_stream.erb +6 -0
- data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
- data/app/views/chats/shared/_unread_badge.html.erb +6 -0
- data/config/importmap.rb +16 -0
- data/config/locales/en.yml +87 -0
- data/config/locales/es.yml +87 -0
- data/config/routes.rb +24 -0
- data/docs/PRD.md +254 -0
- data/docs/campfire_review.md +46 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/chats/broadcasts.rb +147 -0
- data/lib/chats/configuration.rb +286 -0
- data/lib/chats/engine.rb +146 -0
- data/lib/chats/errors.rb +20 -0
- data/lib/chats/macros.rb +28 -0
- data/lib/chats/models/application_record.rb +11 -0
- data/lib/chats/models/concerns/chat_subject.rb +35 -0
- data/lib/chats/models/concerns/messager.rb +102 -0
- data/lib/chats/models/conversation.rb +347 -0
- data/lib/chats/models/message.rb +323 -0
- data/lib/chats/models/participant.rb +151 -0
- data/lib/chats/models/reaction.rb +70 -0
- data/lib/chats/version.rb +5 -0
- data/lib/chats.rb +188 -0
- data/lib/generators/chats/install_generator.rb +62 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
- data/lib/generators/chats/templates/initializer.rb +138 -0
- data/lib/generators/chats/views_generator.rb +49 -0
- metadata +204 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# A message in a conversation. Two kinds:
|
|
5
|
+
#
|
|
6
|
+
# "text" a human message from a +sender+ (any acts_as_messager model)
|
|
7
|
+
# "system" posted by the HOST APP via Conversation#post_system_message!
|
|
8
|
+
# ("Your ride was cancelled") — no sender, centered rendering
|
|
9
|
+
#
|
|
10
|
+
# == Soft deletion ("delete for everyone")
|
|
11
|
+
#
|
|
12
|
+
# With `config.deletion = :soft` (the default), deleting clears the body,
|
|
13
|
+
# purges attachments and stamps +deleted_at+ — the row stays as a tombstone
|
|
14
|
+
# ("Message deleted") exactly like WhatsApp/Telegram, which keeps thread
|
|
15
|
+
# continuity AND leaves an auditable trail for Trust & Safety. Note that
|
|
16
|
+
# moderation evidence is still safe: the moderate gem snapshots reported
|
|
17
|
+
# content AT REPORT TIME, so removing the body later never destroys the
|
|
18
|
+
# evidence a moderator needs.
|
|
19
|
+
#
|
|
20
|
+
# == Real-time
|
|
21
|
+
#
|
|
22
|
+
# All Turbo Stream fan-out lives in Chats::Broadcasts (one place to read
|
|
23
|
+
# the whole live behavior). Broadcasts run in `after_*_commit` callbacks —
|
|
24
|
+
# never inside the transaction — and use the `_later` job variants so
|
|
25
|
+
# rendering never blocks the request.
|
|
26
|
+
class Message < ApplicationRecord
|
|
27
|
+
self.table_name = "chats_messages"
|
|
28
|
+
|
|
29
|
+
KINDS = %w[text system].freeze
|
|
30
|
+
|
|
31
|
+
belongs_to :conversation,
|
|
32
|
+
class_name: "Chats::Conversation",
|
|
33
|
+
inverse_of: :messages,
|
|
34
|
+
counter_cache: :messages_count
|
|
35
|
+
belongs_to :sender, polymorphic: true, optional: true
|
|
36
|
+
belongs_to :reply_to, class_name: "Chats::Message", optional: true
|
|
37
|
+
|
|
38
|
+
has_many :reactions,
|
|
39
|
+
class_name: "Chats::Reaction",
|
|
40
|
+
inverse_of: :message,
|
|
41
|
+
foreign_key: :message_id,
|
|
42
|
+
dependent: :destroy
|
|
43
|
+
|
|
44
|
+
# Attachments ride on ActiveStorage when the host has it installed.
|
|
45
|
+
# What's *allowed* (none / images only / anything) is enforced by
|
|
46
|
+
# validations reading `Chats.config.attachments` — see below.
|
|
47
|
+
has_many_attached :files if defined?(ActiveStorage)
|
|
48
|
+
|
|
49
|
+
# Ruby-side default so metadata is always a Hash even on MySQL, where the
|
|
50
|
+
# migration can't give JSON columns a DB default (MySQL 8+ limitation —
|
|
51
|
+
# see the migration template's json_column_default).
|
|
52
|
+
attribute :metadata, default: -> { {} }
|
|
53
|
+
|
|
54
|
+
scope :recent_first, -> { order(created_at: :desc, id: :desc) }
|
|
55
|
+
scope :oldest_first, -> { order(created_at: :asc, id: :asc) }
|
|
56
|
+
scope :visible, -> { where(deleted_at: nil) }
|
|
57
|
+
|
|
58
|
+
# Keyset (cursor) pagination for infinite scroll-up. OFFSET pagination
|
|
59
|
+
# drifts when new messages arrive mid-scroll; anchoring on the oldest
|
|
60
|
+
# loaded message can't skip or duplicate. `(created_at, id)` because
|
|
61
|
+
# created_at alone isn't unique.
|
|
62
|
+
scope :before_message, lambda { |message|
|
|
63
|
+
where(
|
|
64
|
+
"(chats_messages.created_at < :at) OR (chats_messages.created_at = :at AND chats_messages.id < :id)",
|
|
65
|
+
at: message.created_at, id: message.id
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Catch-up scopes for ConversationsController#refresh (the stale-thread
|
|
70
|
+
# recovery path — see the thread controller's refresh trigger). `since`
|
|
71
|
+
# is the newest `updated_at` the client has rendered:
|
|
72
|
+
# created_since — messages that arrived while the tab was asleep
|
|
73
|
+
# (appended);
|
|
74
|
+
# updated_since — messages the client HAS rendered that changed since
|
|
75
|
+
# (edits, soft-delete tombstones — replaced in place).
|
|
76
|
+
# `updated_since` excludes fresh rows so nothing renders twice — a Turbo
|
|
77
|
+
# append of an existing dom_id would otherwise also MOVE that bubble to
|
|
78
|
+
# the end (Turbo removes-then-appends on id collision).
|
|
79
|
+
# Pattern from Basecamp's Campfire (Rooms::RefreshesController):
|
|
80
|
+
# https://github.com/basecamp/once-campfire
|
|
81
|
+
scope :created_since, ->(time) { where("chats_messages.created_at > ?", time) }
|
|
82
|
+
scope :updated_since, lambda { |time|
|
|
83
|
+
where("chats_messages.updated_at > ?", time).where("chats_messages.created_at <= ?", time)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
validates :kind, inclusion: { in: KINDS }
|
|
87
|
+
validates :body, presence: true, if: :system?
|
|
88
|
+
validate :sender_required_for_text_messages, on: :create
|
|
89
|
+
validate :body_or_files_required
|
|
90
|
+
validate :body_must_fit_length_limit
|
|
91
|
+
validate :sender_must_be_active_participant, on: :create
|
|
92
|
+
validate :sender_must_not_be_blocked, on: :create
|
|
93
|
+
validate :files_must_be_allowed
|
|
94
|
+
|
|
95
|
+
after_create :register_on_conversation
|
|
96
|
+
after_create_commit :broadcast_created
|
|
97
|
+
after_create_commit :notify_host
|
|
98
|
+
after_update_commit :broadcast_updated
|
|
99
|
+
after_destroy_commit :broadcast_destroyed
|
|
100
|
+
after_destroy :heal_conversation_pointers
|
|
101
|
+
|
|
102
|
+
def system? = kind == "system"
|
|
103
|
+
def text? = kind == "text"
|
|
104
|
+
def deleted? = deleted_at.present?
|
|
105
|
+
def edited? = edited_at.present?
|
|
106
|
+
|
|
107
|
+
def sender_key
|
|
108
|
+
sender && Chats.messager_key(sender)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def sent_by?(messager)
|
|
112
|
+
sender.present? && sender == messager
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The body as the UI should show it (tombstones render a localized
|
|
116
|
+
# "Message deleted" placeholder straight from the view, not from here —
|
|
117
|
+
# this just guards against showing stale bodies by accident).
|
|
118
|
+
def visible_body
|
|
119
|
+
deleted? ? nil : body
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Whether +messager+ has read this message, derived from their read
|
|
123
|
+
# horizon (see Chats::Participant for why there's no receipts table).
|
|
124
|
+
def read_by?(messager)
|
|
125
|
+
participant = conversation.participant_for(messager)
|
|
126
|
+
participant&.last_read_at.present? && participant.last_read_at >= created_at
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Edit the body (sender-only — controllers enforce who; the model
|
|
130
|
+
# enforces *that* editing is enabled). Stamps +edited_at+ so the UI can
|
|
131
|
+
# show "edited", and broadcasts the replacement bubble.
|
|
132
|
+
def edit!(new_body)
|
|
133
|
+
raise Chats::NotAllowedError, "editing is disabled" unless Chats.config.editing
|
|
134
|
+
raise Chats::NotAllowedError, "can't edit a deleted message" if deleted?
|
|
135
|
+
|
|
136
|
+
update!(body: new_body, edited_at: Time.current)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Delete according to `config.deletion` (see class comment). Returns
|
|
140
|
+
# false when deletion is disabled.
|
|
141
|
+
def soft_delete!
|
|
142
|
+
case Chats.config.deletion
|
|
143
|
+
when :soft
|
|
144
|
+
transaction do
|
|
145
|
+
files.purge_later if respond_to?(:files) && files.attached?
|
|
146
|
+
update!(body: nil, deleted_at: Time.current)
|
|
147
|
+
end
|
|
148
|
+
true
|
|
149
|
+
when :hard
|
|
150
|
+
destroy!
|
|
151
|
+
true
|
|
152
|
+
else
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def attachments?
|
|
158
|
+
respond_to?(:files) && files.attached?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# --- Moderation contract (duck-typed, zero coupling) ------------------------
|
|
162
|
+
#
|
|
163
|
+
# Plain-Ruby methods that make a message a first-class citizen of the
|
|
164
|
+
# moderate gem the moment the HOST wires it up (see README "Trust &
|
|
165
|
+
# Safety"): they satisfy Moderate::Reportable's contract (owner, label,
|
|
166
|
+
# snapshot, removal, visibility) and Moderate::ContentFilterable's field
|
|
167
|
+
# seams for attachment filtering. Without moderate installed they're
|
|
168
|
+
# inert and cost nothing.
|
|
169
|
+
|
|
170
|
+
def reported_owner
|
|
171
|
+
sender
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def moderation_label
|
|
175
|
+
"Chat message #{id}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def moderation_snapshot(field)
|
|
179
|
+
body if field.to_s == "body"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def removable_reported_field?(field)
|
|
183
|
+
field.to_s == "body" && body.present? && !deleted?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# A moderator removing a reported message body = the soft-delete
|
|
187
|
+
# tombstone path, so the thread shows "Message deleted" instead of a
|
|
188
|
+
# hole, and attachments are purged with it.
|
|
189
|
+
def remove_reported_field!(field)
|
|
190
|
+
return false unless field.to_s == "body"
|
|
191
|
+
|
|
192
|
+
soft_delete!
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Only people *in* the conversation may report a message (a message
|
|
196
|
+
# isn't public content), and you can't report your own.
|
|
197
|
+
def report_visible_to?(viewer, field: nil)
|
|
198
|
+
return false if viewer.nil? || sent_by?(viewer)
|
|
199
|
+
|
|
200
|
+
conversation.participant?(viewer)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def moderation_content_type
|
|
204
|
+
"message"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Moderate::ContentFilterable seam: when the host declares
|
|
208
|
+
# `moderates :files, mode: :flag, with: :some_image_adapter`, the default
|
|
209
|
+
# `public_send(:files)` would hand the adapter an ActiveStorage proxy.
|
|
210
|
+
# Most image adapters (like a custo ImageReviewAdapter, or an AWS
|
|
211
|
+
# Rekognition adapter fed via ClassifyJob) don't read the value anyway —
|
|
212
|
+
# they re-fetch the blob — but we make the value meaningful and
|
|
213
|
+
# change-detection correct regardless.
|
|
214
|
+
def moderation_field_value(field)
|
|
215
|
+
return files if field.to_s == "files" && respond_to?(:files)
|
|
216
|
+
|
|
217
|
+
public_send(field)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def moderation_field_changed_for_commit?(field)
|
|
221
|
+
if field.to_s == "files" && respond_to?(:files)
|
|
222
|
+
# Attachments don't have a column; detect "files changed in this
|
|
223
|
+
# commit" via the attachment records created in this transaction.
|
|
224
|
+
files.attachments.any?(&:previously_new_record?)
|
|
225
|
+
elsif respond_to?(:saved_change_to_attribute?)
|
|
226
|
+
saved_change_to_attribute?(field)
|
|
227
|
+
else
|
|
228
|
+
true
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def sender_required_for_text_messages
|
|
235
|
+
errors.add(:sender, :blank) if text? && sender.nil?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def body_or_files_required
|
|
239
|
+
return if system? || deleted?
|
|
240
|
+
return if body.present?
|
|
241
|
+
return if attachments?
|
|
242
|
+
|
|
243
|
+
errors.add(:body, :blank)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def body_must_fit_length_limit
|
|
247
|
+
max = Chats.config.max_message_length
|
|
248
|
+
return if max.nil? || body.nil?
|
|
249
|
+
|
|
250
|
+
errors.add(:body, :too_long, count: max) if body.length > max
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def sender_must_be_active_participant
|
|
254
|
+
return if system? || sender.nil? || conversation.nil?
|
|
255
|
+
|
|
256
|
+
errors.add(:sender, :not_a_participant) unless conversation.participant?(sender)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Block enforcement at the WRITE, not just at conversation creation: a
|
|
260
|
+
# block placed mid-conversation must stop the very next send. Direct
|
|
261
|
+
# threads only — see Conversation.excluding_blocked_for for the group
|
|
262
|
+
# rationale.
|
|
263
|
+
def sender_must_not_be_blocked
|
|
264
|
+
return if system? || sender.nil? || conversation.nil? || !conversation.direct?
|
|
265
|
+
|
|
266
|
+
other = conversation.other_participants(sender).first&.messager
|
|
267
|
+
errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def files_must_be_allowed
|
|
271
|
+
return unless respond_to?(:files)
|
|
272
|
+
return unless files.attached?
|
|
273
|
+
|
|
274
|
+
mode = Chats.config.attachments
|
|
275
|
+
if mode == false
|
|
276
|
+
errors.add(:files, :not_allowed)
|
|
277
|
+
return
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
if (max_count = Chats.config.max_attachments_per_message) && files.size > max_count
|
|
281
|
+
errors.add(:files, :too_many, count: max_count)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
files.each do |file|
|
|
285
|
+
errors.add(:files, :must_be_images) if mode == :images && !file.content_type.to_s.start_with?("image/")
|
|
286
|
+
|
|
287
|
+
if (max_size = Chats.config.max_attachment_size) && file.byte_size.to_i > max_size
|
|
288
|
+
errors.add(:files, :too_big, count: max_size / (1024 * 1024))
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def register_on_conversation
|
|
294
|
+
conversation.register_last_message!(self)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def heal_conversation_pointers
|
|
298
|
+
conversation.recompute_last_message! if conversation.last_message_id == id
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def broadcast_created
|
|
302
|
+
Chats::Broadcasts.message_created(self)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def broadcast_updated
|
|
306
|
+
Chats::Broadcasts.message_updated(self)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def broadcast_destroyed
|
|
310
|
+
Chats::Broadcasts.message_destroyed(self)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The single notifier hook (see Chats.notify). System messages don't
|
|
314
|
+
# notify: the host posted them itself and already knows — re-emitting
|
|
315
|
+
# would double-count, the same reasoning the chats PRD applies to
|
|
316
|
+
# moderation flag events.
|
|
317
|
+
def notify_host
|
|
318
|
+
return if system?
|
|
319
|
+
|
|
320
|
+
Chats.notify(:message_created, message: self)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# A messager's seat in a conversation. The +messager+ is polymorphic — any
|
|
5
|
+
# `acts_as_messager` model can sit here (a User, an Organization, a support
|
|
6
|
+
# Agent) — and ALL per-member state lives on this row:
|
|
7
|
+
#
|
|
8
|
+
# role "member" | "owner" (group creator/admin)
|
|
9
|
+
# last_read_at the read horizon (see "Read state" below)
|
|
10
|
+
# muted_at notifications muted (the gem still delivers messages;
|
|
11
|
+
# hosts consult `notifiable?` in their notifier hook)
|
|
12
|
+
# left_at soft-left groups (history kept, no new messages seen)
|
|
13
|
+
# last_notified_at bookkeeping for "notify once per unread burst"
|
|
14
|
+
# debounced notifications (see #should_notify?)
|
|
15
|
+
#
|
|
16
|
+
# == Read state: a horizon, not per-message receipts
|
|
17
|
+
#
|
|
18
|
+
# We deliberately store ONE timestamp per participant instead of a
|
|
19
|
+
# per-message receipts table. A message is unread for you iff
|
|
20
|
+
# `created_at > your last_read_at`; you "read" a conversation by advancing
|
|
21
|
+
# the horizon. This gives unread counts, unread badges and "Seen"
|
|
22
|
+
# indicators with zero extra writes per message (a receipts table writes
|
|
23
|
+
# N rows per message per N participants — the classic chat-schema scaling
|
|
24
|
+
# trap), and it's exactly how Basecamp's Campfire models it. If a future
|
|
25
|
+
# use case truly needs per-message receipts (e.g. per-member "read by 7/9"
|
|
26
|
+
# in large groups), they can be added as a new table without breaking this
|
|
27
|
+
# API.
|
|
28
|
+
class Participant < ApplicationRecord
|
|
29
|
+
self.table_name = "chats_participants"
|
|
30
|
+
|
|
31
|
+
ROLES = %w[member owner].freeze
|
|
32
|
+
|
|
33
|
+
belongs_to :conversation, class_name: "Chats::Conversation", inverse_of: :participants
|
|
34
|
+
belongs_to :messager, polymorphic: true
|
|
35
|
+
|
|
36
|
+
scope :active, -> { where(left_at: nil) }
|
|
37
|
+
scope :muted, -> { where.not(muted_at: nil) }
|
|
38
|
+
|
|
39
|
+
validates :role, inclusion: { in: ROLES }
|
|
40
|
+
# One-seat-per-messager uniqueness is enforced by the DB unique index
|
|
41
|
+
# ONLY: Conversation#add_participant! relies on `create_or_find_by!`,
|
|
42
|
+
# which needs the index violation (not a pre-insert validation) to make
|
|
43
|
+
# concurrent joins race-safe. Same rationale as Conversation#direct_key.
|
|
44
|
+
validate :group_must_have_room, on: :create
|
|
45
|
+
|
|
46
|
+
def owner? = role == "owner"
|
|
47
|
+
def left? = left_at.present?
|
|
48
|
+
def active? = left_at.nil?
|
|
49
|
+
def muted? = muted_at.present?
|
|
50
|
+
|
|
51
|
+
def display_name
|
|
52
|
+
Chats.display_name_for(messager)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- Read state -----------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
# Messages this participant hasn't seen: anything newer than their read
|
|
58
|
+
# horizon, excluding their own messages and deleted tombstones. The
|
|
59
|
+
# explicit IS NULL leg keeps senderless SYSTEM messages counted — SQL's
|
|
60
|
+
# three-valued logic would otherwise drop them (NOT(NULL = …) is NULL,
|
|
61
|
+
# not TRUE). Same fix as Conversation.unread_by.
|
|
62
|
+
def unread_messages
|
|
63
|
+
conversation.messages.visible
|
|
64
|
+
.where("chats_messages.created_at > ?", last_read_at || Conversation::EPOCH)
|
|
65
|
+
.where(
|
|
66
|
+
"chats_messages.sender_type IS NULL OR " \
|
|
67
|
+
"NOT (chats_messages.sender_type = ? AND chats_messages.sender_id = ?)",
|
|
68
|
+
messager_type, messager_id.to_s
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def unread_count
|
|
73
|
+
unread_messages.count
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def unread?
|
|
77
|
+
unread_messages.exists?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Advance the read horizon to now and tell everyone who cares:
|
|
81
|
+
# - the conversation stream gets a fresh read-state payload (powers the
|
|
82
|
+
# "Seen" indicator on the other side, when read receipts are on)
|
|
83
|
+
# - this messager's OWN inbox + badge refresh (their other devices/tabs
|
|
84
|
+
# should drop the unread highlight too)
|
|
85
|
+
# - the HOST gets a `:conversation_read` notifier event — but only when
|
|
86
|
+
# the horizon actually swallowed unread content. Hosts use it to keep
|
|
87
|
+
# external notification surfaces truthful (e.g. mark this chat's rows
|
|
88
|
+
# read in a notification center the moment the thread is read, so a
|
|
89
|
+
# bell badge doesn't keep advertising messages the user has already
|
|
90
|
+
# seen). Same notify hook as :message_created; error-isolated.
|
|
91
|
+
def read!(at: Time.current)
|
|
92
|
+
return self if last_read_at && last_read_at >= at
|
|
93
|
+
|
|
94
|
+
had_unread = unread?
|
|
95
|
+
update!(last_read_at: at)
|
|
96
|
+
broadcast_read_state if Chats.config.read_receipts
|
|
97
|
+
Chats::Broadcasts.refresh_inbox_of(messager)
|
|
98
|
+
Chats::Broadcasts.update_badge_of(messager)
|
|
99
|
+
Chats.notify(:conversation_read, conversation: conversation, participant: self) if had_unread
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def mute! = update!(muted_at: Time.current)
|
|
104
|
+
def unmute! = update!(muted_at: nil)
|
|
105
|
+
|
|
106
|
+
def leave!
|
|
107
|
+
update!(left_at: Time.current)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- Notification etiquette (for host notifier hooks) ----------------------
|
|
111
|
+
|
|
112
|
+
# Should the host notify this participant about +message+? Encapsulates
|
|
113
|
+
# the etiquette every messaging product implements so each host doesn't
|
|
114
|
+
# re-derive it: don't notify yourself, the muted, the departed — and for
|
|
115
|
+
# debounced email digests, don't notify twice for the same unread burst.
|
|
116
|
+
def notifiable_for?(message)
|
|
117
|
+
return false if left? || muted?
|
|
118
|
+
return false if message.sender == messager
|
|
119
|
+
|
|
120
|
+
true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# For "email me only once until I come back" digests: true when there's
|
|
124
|
+
# something unread AND we haven't already notified since the last read.
|
|
125
|
+
# Pair with `mark_notified!` after actually sending.
|
|
126
|
+
def should_notify?
|
|
127
|
+
return false unless unread?
|
|
128
|
+
|
|
129
|
+
last_notified_at.nil? || last_notified_at < (last_read_at || Conversation::EPOCH)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def mark_notified!(at: Time.current)
|
|
133
|
+
update!(last_notified_at: at)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def group_must_have_room
|
|
139
|
+
return if conversation.nil? || conversation.direct?
|
|
140
|
+
|
|
141
|
+
max = Chats.config.max_group_size
|
|
142
|
+
return unless max && conversation.participants.active.count >= max
|
|
143
|
+
|
|
144
|
+
errors.add(:base, :group_full, count: max)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def broadcast_read_state
|
|
148
|
+
Chats::Broadcasts.read_state(conversation)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# An emoji reaction on a message. One row per (message, reactor, emoji) —
|
|
5
|
+
# the unique index makes `toggle!` race-safe. Reactors are polymorphic like
|
|
6
|
+
# every actor in this gem.
|
|
7
|
+
class Reaction < ApplicationRecord
|
|
8
|
+
self.table_name = "chats_reactions"
|
|
9
|
+
|
|
10
|
+
# Reactions are short by nature; 16 chars comfortably fits any emoji
|
|
11
|
+
# grapheme cluster (ZWJ sequences like 👨👩👧👦 are up to ~11 chars) while
|
|
12
|
+
# making "smuggle a paragraph into a reaction" impossible.
|
|
13
|
+
EMOJI_MAX_LENGTH = 16
|
|
14
|
+
|
|
15
|
+
belongs_to :message, class_name: "Chats::Message", inverse_of: :reactions
|
|
16
|
+
belongs_to :reactor, polymorphic: true
|
|
17
|
+
|
|
18
|
+
validates :emoji, presence: true, length: { maximum: EMOJI_MAX_LENGTH }
|
|
19
|
+
validates :emoji, uniqueness: { scope: %i[message_id reactor_type reactor_id] }
|
|
20
|
+
validate :reactions_must_be_enabled, on: :create
|
|
21
|
+
validate :reactor_must_be_participant, on: :create
|
|
22
|
+
|
|
23
|
+
# ONE after_commit with on: — NOT separate after_create_commit +
|
|
24
|
+
# after_destroy_commit macros: registering the SAME method name through
|
|
25
|
+
# two *_commit macros silently keeps only the last registration (each
|
|
26
|
+
# macro defines an after_commit filter keyed by method name). Documented
|
|
27
|
+
# Rails behavior: https://guides.rubyonrails.org/active_record_callbacks.html#using-both-after-create-commit-and-after-update-commit
|
|
28
|
+
after_commit :broadcast_change, on: %i[create destroy]
|
|
29
|
+
|
|
30
|
+
# Add the reaction if absent, remove it if present (the universal
|
|
31
|
+
# tap-to-toggle semantic). Returns the created reaction, or false when
|
|
32
|
+
# toggled off. Race-safe: a concurrent double-tap resolves through the
|
|
33
|
+
# unique index instead of raising.
|
|
34
|
+
def self.toggle!(message:, reactor:, emoji:)
|
|
35
|
+
existing = find_by(message: message, reactor: reactor, emoji: emoji)
|
|
36
|
+
if existing
|
|
37
|
+
existing.destroy!
|
|
38
|
+
false
|
|
39
|
+
else
|
|
40
|
+
create!(message: message, reactor: reactor, emoji: emoji)
|
|
41
|
+
end
|
|
42
|
+
rescue ActiveRecord::RecordNotUnique
|
|
43
|
+
# Lost the race with an identical create — treat as toggle-off.
|
|
44
|
+
find_by(message: message, reactor: reactor, emoji: emoji)&.destroy!
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Grouped summary for rendering: [["👍", 3], ["🚗", 1]] — stable order so
|
|
49
|
+
# bubbles don't shuffle when counts change.
|
|
50
|
+
def self.summary_for(message)
|
|
51
|
+
where(message: message).group(:emoji).count.sort_by { |emoji, _count| emoji }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def reactions_must_be_enabled
|
|
57
|
+
errors.add(:base, :reactions_disabled) unless Chats.config.reactions
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reactor_must_be_participant
|
|
61
|
+
return if message.nil? || reactor.nil?
|
|
62
|
+
|
|
63
|
+
errors.add(:reactor, :not_a_participant) unless message.conversation.participant?(reactor)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def broadcast_change
|
|
67
|
+
Chats::Broadcasts.message_updated(message) unless message.destroyed?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|