chats 0.1.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +233 -0
  3. data/README.md +214 -28
  4. data/app/assets/stylesheets/chats.css +86 -0
  5. data/app/controllers/chats/conversations_controller.rb +39 -42
  6. data/app/controllers/chats/messages_controller.rb +34 -0
  7. data/app/controllers/chats/reactions_controller.rb +14 -0
  8. data/app/helpers/chats/engine_helper.rb +113 -2
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
  11. data/app/views/chats/conversations/_group.html.erb +41 -0
  12. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  13. data/app/views/chats/conversations/index.html.erb +39 -9
  14. data/app/views/chats/conversations/show.html.erb +48 -3
  15. data/app/views/chats/messages/_composer.html.erb +4 -0
  16. data/app/views/chats/messages/_message.html.erb +29 -8
  17. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  18. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  19. data/config/importmap.rb +2 -1
  20. data/config/locales/en.yml +13 -0
  21. data/config/locales/es.yml +13 -0
  22. data/context7.json +4 -0
  23. data/docs/PRD.md +1 -1
  24. data/docs/campfire_review.md +1 -1
  25. data/gemfiles/rails_7.1.gemfile +1 -0
  26. data/gemfiles/rails_7.2.gemfile +1 -0
  27. data/gemfiles/rails_8.1.gemfile +1 -0
  28. data/lib/chats/configuration.rb +79 -1
  29. data/lib/chats/engine.rb +29 -7
  30. data/lib/chats/errors.rb +16 -0
  31. data/lib/chats/inbox.rb +303 -0
  32. data/lib/chats/inbox_group.rb +75 -0
  33. data/lib/chats/macros.rb +33 -1
  34. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  35. data/lib/chats/models/concerns/messager.rb +99 -2
  36. data/lib/chats/models/conversation.rb +78 -7
  37. data/lib/chats/models/message.rb +67 -3
  38. data/lib/chats/models/participant.rb +59 -0
  39. data/lib/chats/models/reaction.rb +5 -0
  40. data/lib/chats/subscribers.rb +156 -0
  41. data/lib/chats/version.rb +1 -1
  42. data/lib/chats.rb +122 -15
  43. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  44. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  45. data/lib/generators/chats/templates/initializer.rb +106 -14
  46. data/lib/generators/chats/upgrade_generator.rb +48 -0
  47. metadata +13 -2
@@ -141,7 +141,12 @@ module Chats
141
141
  # `create_or_find_by!` may have FOUND a conversation created a moment
142
142
  # ago by the other side — participants are ensured idempotently
143
143
  # either way (their own unique index makes this race-safe too).
144
+ # `previously_new_record?` is how we tell the two apart, so the
145
+ # :conversation_created event fires ONCE per conversation, not on
146
+ # every resume.
147
+ created = conversation.previously_new_record?
144
148
  [a, b].each { |messager| conversation.add_participant!(messager) }
149
+ Chats.notify(:conversation_created, conversation: conversation) if created
145
150
  conversation
146
151
  end
147
152
 
@@ -158,12 +163,17 @@ module Chats
158
163
  others = Array(others) - [creator]
159
164
  raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
160
165
 
161
- transaction do
162
- conversation = create!(kind: "group", title: title, subject: about)
163
- conversation.add_participant!(creator, role: "owner")
164
- others.each { |messager| conversation.add_participant!(messager) }
165
- conversation
166
+ conversation = transaction do
167
+ created = create!(kind: "group", title: title, subject: about)
168
+ created.add_participant!(creator, role: "owner")
169
+ others.each { |messager| created.add_participant!(messager) }
170
+ created
166
171
  end
172
+
173
+ # Emitted AFTER the transaction: subscribers see a complete roster
174
+ # and never run inside the write that created it.
175
+ Chats.notify(:conversation_created, conversation: conversation)
176
+ conversation
167
177
  end
168
178
 
169
179
  # Deterministic identity for a direct pair (+ optional subject).
@@ -212,13 +222,30 @@ module Chats
212
222
  )
213
223
  end
214
224
 
225
+ # The OTHER messager in a direct thread, from +viewer+'s seat — nil for a
226
+ # group, and nil for a direct thread whose other seat has left. The one
227
+ # place the counterpart is resolved, so the title, the avatar and the
228
+ # verified badge on an inbox row always name the same person.
229
+ #
230
+ # Memoized per viewer: an inbox row asks for it two or three times, and a
231
+ # row that cost one query in 0.2.0 must not start costing three.
232
+ def counterpart_for(viewer)
233
+ return nil unless direct?
234
+
235
+ @counterparts ||= {}
236
+ key = viewer && Chats.messager_key(viewer)
237
+ return @counterparts[key] if @counterparts.key?(key)
238
+
239
+ @counterparts[key] = other_participants(viewer).includes(:messager).first&.messager
240
+ end
241
+
215
242
  # What this conversation is called from +viewer+'s seat: a direct thread
216
243
  # is named after the counterpart; a group after its title (or its
217
244
  # members, when untitled).
218
245
  def title_for(viewer)
219
246
  if direct?
220
- other = other_participants(viewer).includes(:messager).first
221
- other ? Chats.display_name_for(other.messager) : I18n.t("chats.conversation.empty_title")
247
+ other = counterpart_for(viewer)
248
+ other ? Chats.display_name_for(other) : I18n.t("chats.conversation.empty_title")
222
249
  else
223
250
  title.presence || participants.active.includes(:messager).limit(4).map do |p|
224
251
  Chats.display_name_for(p.messager)
@@ -235,6 +262,31 @@ module Chats
235
262
  subject.try(:chat_subject_label) || "#{subject.class.model_name.human} #{subject.id}"
236
263
  end
237
264
 
265
+ # Whether new messages are refused here, decided by the SUBJECT (see
266
+ # Chats::ChatSubject#chat_locked?). A subjectless conversation is never
267
+ # locked. Reading is never affected — only sending.
268
+ def locked?
269
+ return false if subject.nil?
270
+
271
+ subject.try(:chat_locked?) || false
272
+ end
273
+
274
+ # The host's explanation for the lock, or the gem's localized fallback.
275
+ # Always a sentence worth showing: a locked composer that says nothing is
276
+ # indistinguishable from a broken one.
277
+ def locked_notice
278
+ return nil unless locked?
279
+
280
+ subject.try(:chat_locked_notice).presence || I18n.t("chats.composer.locked")
281
+ end
282
+
283
+ # The guard every write that ISN'T a message itself calls (reactions
284
+ # today). Message writes go through Chats::Message#refuse_when_locked!,
285
+ # which exempts system messages.
286
+ def refuse_writes_when_locked! # :nodoc:
287
+ raise Chats::LockedError.new(conversation: self) if locked?
288
+ end
289
+
238
290
  # --- Membership -----------------------------------------------------------
239
291
 
240
292
  # Idempotent, race-safe membership. Re-adding someone who left re-joins
@@ -250,6 +302,25 @@ module Chats
250
302
  participant
251
303
  end
252
304
 
305
+ # Recompute the deterministic identity of a DIRECT thread after its
306
+ # roster changed (see Chats::Participant#reseat!). Without this the key
307
+ # would still name the old pair, and `chat_with` would open a SECOND
308
+ # thread for the new one. No-op for groups (they have no key).
309
+ def reindex_direct_key! # :nodoc:
310
+ return self unless direct?
311
+
312
+ # `reset`: the caller just changed a seat, and a participants
313
+ # association loaded BEFORE that would name the old pair.
314
+ messagers = participants.reset.includes(:messager).filter_map(&:messager)
315
+ return self unless messagers.size == 2
316
+
317
+ update_columns(
318
+ direct_key: self.class.direct_key_for(messagers, subject: subject),
319
+ updated_at: Time.current
320
+ )
321
+ self
322
+ end
323
+
253
324
  # --- Messaging ------------------------------------------------------------
254
325
 
255
326
  # Post a message from your APP into the conversation — "Your ride was
@@ -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
- def soft_delete!
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
- soft_delete!
235
+ # Trust & Safety outranks a product lock: a closed conversation must
236
+ # never be a place reported content can hide.
237
+ soft_delete!(enforce_lock: false)
193
238
  end
194
239
 
195
240
  # Only people *in* the conversation may report a message (a message
@@ -267,6 +312,25 @@ module Chats
267
312
  errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
268
313
  end
269
314
 
315
+ # An author signs the bubble with `Chats.display_name_for`, so it has to
316
+ # be something that HAS a name in this system — a messager, not a ride or
317
+ # a listing that would render as "Listing 1".
318
+ def author_must_be_a_messager
319
+ return if author.nil? || Chats.messager_class?(author.class)
320
+
321
+ errors.add(:author, :not_a_messager)
322
+ end
323
+
324
+ # The subject owns the conversation's openness (Chats::ChatSubject#
325
+ # chat_locked?). System messages are exempt: the host must always be able
326
+ # to post "This ticket was closed" into the thread it just closed.
327
+ def conversation_must_not_be_locked
328
+ return if system? || conversation.nil?
329
+ return unless conversation.locked?
330
+
331
+ errors.add(:base, :locked)
332
+ end
333
+
270
334
  def files_must_be_allowed
271
335
  return unless respond_to?(:files)
272
336
  return unless files.attached?
@@ -105,6 +105,37 @@ module Chats
105
105
 
106
106
  def leave!
107
107
  update!(left_at: Time.current)
108
+ Chats.notify(:participant_left, participant: self)
109
+ self
110
+ end
111
+
112
+ # Hand this seat to a different messager, keeping the read horizon, the
113
+ # role and the history: the guest who signs up, the agent who takes over
114
+ # a shared mailbox. The MESSAGES keep their original sender — what was
115
+ # said was said by whoever said it.
116
+ #
117
+ # Direct conversations have their +direct_key+ recomputed, so the thread
118
+ # keeps resolving through `chat_with` for the NEW pair instead of
119
+ # stranding a duplicate.
120
+ def reseat!(new_messager)
121
+ raise ArgumentError, "reseat! requires a messager" if new_messager.nil?
122
+ unless Chats.messager_class?(new_messager.class)
123
+ raise Chats::NotAllowedError, "#{new_messager.class.name} is not a messager (acts_as_messager)"
124
+ end
125
+
126
+ refuse_reseat_conflicts!(new_messager)
127
+
128
+ transaction do
129
+ update!(messager: new_messager)
130
+ conversation.reindex_direct_key!
131
+ end
132
+
133
+ self
134
+ rescue ActiveRecord::RecordNotUnique
135
+ # The race backstop for the checks above (two reseats, or a DM opened,
136
+ # between the check and the write). Translated so a host never has a
137
+ # driver-level exception poison its transaction.
138
+ raise Chats::NotAllowedError, "that conversation already exists for the new pair"
108
139
  end
109
140
 
110
141
  # --- Notification etiquette (for host notifier hooks) ----------------------
@@ -114,6 +145,10 @@ module Chats
114
145
  # re-derive it: don't notify yourself, the muted, the departed — and for
115
146
  # debounced email digests, don't notify twice for the same unread burst.
116
147
  def notifiable_for?(message)
148
+ # Headless messagers (`acts_as_messager notifications: false`) — a
149
+ # support desk, a bot, an org mailbox — are never notifiable. This is
150
+ # THE reason hosts no longer branch on class in their notifiers.
151
+ return false unless Chats.notifications_for?(messager)
117
152
  return false if left? || muted?
118
153
  return false if message.sender == messager
119
154
 
@@ -135,6 +170,30 @@ module Chats
135
170
 
136
171
  private
137
172
 
173
+ # Everything that would make the reseat collide, checked BEFORE the
174
+ # write. A unique-index violation inside the transaction would abort the
175
+ # host's transaction too on PostgreSQL, so the pre-check is the real
176
+ # guard and the RecordNotUnique rescue is only the race backstop.
177
+ def refuse_reseat_conflicts!(new_messager)
178
+ if conversation.participants.where.not(id: id).exists?(
179
+ messager_type: new_messager.class.polymorphic_name, messager_id: new_messager.id
180
+ )
181
+ raise Chats::NotAllowedError, "that messager already has a seat in this conversation"
182
+ end
183
+
184
+ return unless conversation.direct?
185
+
186
+ other = conversation.other_participants(messager).includes(:messager).first&.messager
187
+ return if other.nil?
188
+
189
+ existing = Chats::Conversation.direct_between(new_messager, other, about: conversation.subject)
190
+ return if existing.nil? || existing == conversation
191
+
192
+ raise Chats::NotAllowedError,
193
+ "#{Chats.display_name_for(new_messager)} already has a direct conversation with " \
194
+ "#{Chats.display_name_for(other)}"
195
+ end
196
+
138
197
  def group_must_have_room
139
198
  return if conversation.nil? || conversation.direct?
140
199
 
@@ -32,6 +32,11 @@ module Chats
32
32
  # toggled off. Race-safe: a concurrent double-tap resolves through the
33
33
  # unique index instead of raising.
34
34
  def self.toggle!(message:, reactor:, emoji:)
35
+ # Reacting is a write to the conversation, so a locked one refuses it —
36
+ # in BOTH directions: you can't add a reaction to a closed thread, and
37
+ # you can't take one back either. Same rule as editing and deleting.
38
+ message&.conversation&.refuse_writes_when_locked!
39
+
35
40
  existing = find_by(message: message, reactor: reactor, emoji: emoji)
36
41
  if existing
37
42
  existing.destroy!
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # The event bus behind `Chats.on`. One registry per event, many
5
+ # subscribers per event, each one isolated:
6
+ #
7
+ # Chats.on(:message_created) { |message| NewMessageNotifier.deliver(message) }
8
+ # Chats.on(:conversation_read) { |conversation:, participant:| Bell.mark_read(participant) }
9
+ #
10
+ # Two rules make this safe to use from a gem (the shape the `wallets` gem's
11
+ # CallbackDispatcher established):
12
+ #
13
+ # 1. A raising subscriber is REPORTED, never swallowed and never fatal —
14
+ # `Rails.error.report(e, handled: true, context: { event: })` — so the
15
+ # next subscriber still runs and the message still gets delivered.
16
+ # 2. Registration is reload-safe: pass `key:` and re-registering the same
17
+ # key REPLACES the subscriber in place, so a `to_prepare` block in a
18
+ # host app doesn't stack duplicates on every code reload.
19
+ module Subscribers
20
+ # Every event the gem emits, mapped to the payload key it yields
21
+ # POSITIONALLY to subscribers (nil = the whole payload is yielded as
22
+ # keywords). Registering for anything else raises at boot.
23
+ #
24
+ # :message_created message: every persisted human message
25
+ # :conversation_created conversation: a conversation just came into being
26
+ # :participant_left participant: someone left a group
27
+ # :conversation_read conversation:, participant: a read consumed unread content
28
+ EVENTS = {
29
+ message_created: :message,
30
+ conversation_created: :conversation,
31
+ participant_left: :participant,
32
+ conversation_read: nil
33
+ }.freeze
34
+
35
+ # The events the deprecated `config.notifier=` hook is subscribed to:
36
+ # exactly the two that existed in 0.1.1, and no more. A 0.1.x notifier is
37
+ # commonly written `->(event, message:, **)`, which would raise
38
+ # ArgumentError on an event that carries no `message:` — so the new
39
+ # events are `Chats.on` only, and an old hook keeps behaving exactly as
40
+ # it did.
41
+ LEGACY_NOTIFIER_EVENTS = %i[message_created conversation_read].freeze
42
+
43
+ # Reserved key for the subscriber `config.notifier=` registers, so
44
+ # re-assigning the deprecated hook replaces rather than stacks.
45
+ NOTIFIER_KEY = :chats_config_notifier
46
+
47
+ # One registered callable. +style+ is how it gets invoked:
48
+ # :payload the modern `Chats.on` shape (positional record, or keywords)
49
+ # :event the deprecated `config.notifier` shape — `(event, **payload)`
50
+ class Subscriber
51
+ attr_reader :key, :callable, :style
52
+
53
+ def initialize(callable, key: nil, style: :payload)
54
+ @callable = callable
55
+ @key = key
56
+ @style = style
57
+ end
58
+
59
+ # Invoke the subscriber for +event+ with the emitted +payload+ hash.
60
+ def call(event, payload)
61
+ if style == :event
62
+ callable.call(event, **payload)
63
+ elsif (positional = EVENTS[event])
64
+ callable.call(payload[positional])
65
+ else
66
+ callable.call(**payload)
67
+ end
68
+ end
69
+ end
70
+
71
+ class << self
72
+ # Register +block+ for +event+. Returns the Subscriber.
73
+ def on(event, key: nil, style: :payload, &block)
74
+ event = validate_event!(event)
75
+ raise ConfigurationError, "Chats.on(#{event.inspect}) requires a block" if block.nil?
76
+
77
+ subscriber = Subscriber.new(block, key: key, style: style)
78
+ replace_or_append(registry[event], subscriber)
79
+ subscriber
80
+ end
81
+
82
+ # Every subscriber registered for +event+, in registration order.
83
+ def for(event)
84
+ registry[validate_event!(event)].dup
85
+ end
86
+
87
+ # Run every subscriber of +event+, each isolated from the others.
88
+ # Returns the number of subscribers invoked.
89
+ #
90
+ # Emitting is never allowed to raise: it runs inside `after_commit`
91
+ # hooks, where an exception would punish a write that already
92
+ # succeeded. An unknown event here is a bug in the CALLER, so it is
93
+ # logged and skipped; `Chats.on` is where a bad event name fails loudly,
94
+ # at boot, where someone can fix it.
95
+ def emit(event, **payload)
96
+ event = event.to_sym
97
+ unless EVENTS.key?(event)
98
+ Chats.logger&.error("[chats] ignoring unknown event #{event.inspect}")
99
+ return 0
100
+ end
101
+
102
+ subscribers = registry[event]
103
+
104
+ subscribers.each do |subscriber|
105
+ subscriber.call(event, payload)
106
+ rescue StandardError => e
107
+ report(e, event)
108
+ end
109
+
110
+ subscribers.size
111
+ end
112
+
113
+ # Drop every registration (used by `Chats.reset!` and by hosts that
114
+ # re-register from a `to_prepare` block).
115
+ def reset!
116
+ @registry = nil
117
+ self
118
+ end
119
+
120
+ private
121
+
122
+ def registry
123
+ @registry ||= EVENTS.keys.index_with { [] }
124
+ end
125
+
126
+ def validate_event!(event)
127
+ event = event.to_sym
128
+ return event if EVENTS.key?(event)
129
+
130
+ raise ConfigurationError,
131
+ "unknown chats event #{event.inspect} — valid events are #{EVENTS.keys.map(&:inspect).join(", ")}"
132
+ end
133
+
134
+ # Keyed subscribers replace in place (same position, so ordering is
135
+ # stable across code reloads); keyless ones always append.
136
+ def replace_or_append(list, subscriber)
137
+ index = subscriber.key && list.index { |existing| existing.key == subscriber.key }
138
+ if index
139
+ list[index] = subscriber
140
+ else
141
+ list << subscriber
142
+ end
143
+ end
144
+
145
+ # A failing subscriber must be VISIBLE (not just logged): the host's
146
+ # error reporter is the one surface that pages someone.
147
+ def report(error, event)
148
+ if defined?(::Rails) && ::Rails.respond_to?(:error) && ::Rails.error
149
+ ::Rails.error.report(error, handled: true, context: { event: event })
150
+ else
151
+ Chats.logger&.error("[chats] subscriber raised on #{event}: #{error.class}: #{error.message}")
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
data/lib/chats/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chats
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.1"
5
5
  end