chats 0.4.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: baea21fb7f7a5ce0c50ec662b84fbef24adaa4ceb0c12a876de29891f88a2419
4
- data.tar.gz: c199fc0c645bd667b7672e1ca0ea7257eb0e9cf05179c576f606fcf784b9feb0
3
+ metadata.gz: f2f76b973f012e2c86514932efaca3db25eaf8c70ed4709276d1f5f33d520de2
4
+ data.tar.gz: 2b88c68ad0d239a9ba87b5e907193924926a3926715ed39b62f844e2d899bf19
5
5
  SHA512:
6
- metadata.gz: 7739f616cf9e555ea1ed2e6321937bc6698d9e08733ee6c496d95ed41e2331694b5340ab5be641ac2e73875a17c4a4859bfa09e01cb0b3984a5b4fa340dcceb1
7
- data.tar.gz: 712ac6d49724af1505807c3aaadd4f10fd52c8fec9653efbb9d4a89843be6fa0b2ec680873e240cc4c2bc139048cb5796b1165fced263992dc13d1499d2da0fc
6
+ metadata.gz: be31c3ce5aad04e1c9d5d0ba141403514da0f547de42a4516ede78117025a12c7f3936060787a3935e477e4f2c03aa2fcfb7b7550c0fdca77a5c6d65a0c86db3
7
+ data.tar.gz: f22af91cf0ccb50bd933659d0fdc549306ecb2bfad09ee87cfb0193499a0839c2f468cd8e109ad7266ddba35200126b28d5f8958b065420fd828a065b9f40b80
data/CHANGELOG.md CHANGED
@@ -4,6 +4,61 @@ All notable changes to this project are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.2] - 2026-10-17
8
+
9
+ ### Changed
10
+ **Upgrade note: your `can_message` policy now runs on every direct send,
11
+ not only when a thread is opened.** Read yours before upgrading. If it
12
+ means "who may OPEN a conversation" (a listing must be live, a ride must be
13
+ accepted), existing threads will stop taking messages the moment it turns
14
+ false for the pair — which is usually what you want (a block already
15
+ behaves this way), but is a change. If it is expensive, it now costs that
16
+ on every direct message; make it cheap or cache what it reads. It must not
17
+ raise: an exception escapes the send like any other host error. Group
18
+ conversations and system messages are unchanged.
19
+
20
+ - **`can_message` holds on every direct send.** A block placed
21
+ mid-conversation already stopped the next message; the host's policy
22
+ turning false did not, so a pair allowed when the thread was opened kept
23
+ talking after an account was banned or a membership ended. Every text
24
+ message into a direct conversation now re-runs `Chats.can_message?`
25
+ exactly like the block check: `:blocked` when either side blocked the
26
+ other, `:not_allowed` (new, in `en` and `es`) when the policy says no.
27
+ The policy is asked last, only of a send that is otherwise valid (a
28
+ stranger, a locked thread or an empty body is refused before it runs).
29
+ The composer answers with its errors stream as before.
30
+ - **A direct thread with nobody on the other side takes no messages.** When
31
+ the counterpart's account was destroyed, its seat went with it (the
32
+ gem's `dependent: :destroy` on participations); a text message into that
33
+ thread is refused with `:not_allowed` instead of slipping past both the
34
+ block and the policy checks. The thread stays readable.
35
+
36
+ ## [0.4.1] - 2026-10-17
37
+
38
+ ### Fixed
39
+ - **An action's strings are frozen copies.** `Data` freezes the action but
40
+ not the strings it was handed, so `action.url.replace("javascript:…")`,
41
+ or the caller mutating the string they passed in, could rewrite a
42
+ validated action in memory before the bubble rendered it. `Chats::Action`
43
+ now keeps a frozen copy of both members: what the validator saw is what
44
+ renders.
45
+ - **An empty port suffix is refused.** `https://host:/x` parses as the
46
+ default port and slipped past the 1..65535 rule; a named port must have
47
+ digits.
48
+ - **An action's label is a plain String.** A label handed over as an
49
+ html_safe string (a host's `t("…_html")`) kept the mark through the copy
50
+ and rendered unescaped in the button. The copy is now a plain `String`,
51
+ so the bubble escapes it like any other content.
52
+ - **`config.inbox_scope` is one rule, applied once.** `Messager#chats` now
53
+ applies it, so the inbox relation, `unread_chats_count`, the inbox screen
54
+ and the live badge all agree. The badge broadcast used to count through
55
+ the raw `inbox_for` relation and could light up for a conversation the
56
+ inbox would never show (a host scoping game records out of the inbox saw
57
+ the badge count them). `Chats::Inbox` builds on `viewer.chats`, so a
58
+ host's own override of `chats` reaches the inbox screen too.
59
+ - The README said the inbox preview still shows a soft-deleted message's
60
+ body; it reads "Message deleted", as for any tombstone.
61
+
7
62
  ## [0.4.0] - 2026-10-17
8
63
 
9
64
  Action buttons. A support desk writes "Your invoice is ready" and wants a
data/README.md CHANGED
@@ -171,7 +171,7 @@ That single hook makes moderate's bidirectional block table the law everywhere c
171
171
  - **can't send** into an existing one (a block placed mid-conversation stops the very next message),
172
172
  - and **stop seeing** each other's direct threads in the inbox and unread counts — *hidden, never deleted*: lift the block and the history reappears.
173
173
 
174
- Two semantics worth knowing: blocking is enforced **beneath** your `can_message` policy (a permissive or buggy policy can never let a blocked pair talk), and **group conversations are exempt from pair blocks** — the industry standard: blocking someone removes your private line, not your seat in shared spaces. If your domain should eject blocked members from groups, do it in moderate's `on_block` hook by tearing down whatever domain relationship feeds the group membership.
174
+ Two semantics worth knowing: blocking is enforced **beneath** your `can_message` policy (a permissive or buggy policy can never let a blocked pair talk), both are checked on **every direct send**, not only when the thread is opened (a block placed, or a policy turning false, mid-conversation stops the very next message with `:blocked` / `:not_allowed`), and **group conversations are exempt from pair blocks** — the industry standard: blocking someone removes your private line, not your seat in shared spaces. If your domain should eject blocked members from groups, do it in moderate's `on_block` hook by tearing down whatever domain relationship feeds the group membership.
175
175
 
176
176
  ### 2. Reportable + filtered messages
177
177
 
@@ -469,7 +469,7 @@ Chats::Action.new(label: "Pay now", url: "/pay") # accepted anywhere a Hash is
469
469
  - `url`: at most 2048 characters, and EITHER an absolute `http://` / `https://` URL with a host (and, if it names one, a port from 1 to 65535), OR a path on your site that starts with a single `/`. Everything else is refused: `javascript:`, `data:`, `mailto:`, relative paths, and anything a browser would send to another site: `//elsewhere.example`, `///elsewhere.example`, `/\elsewhere.example`, `/<tab>/…`. URLs must be escaped, as Rails' url helpers already produce them.
470
470
  - The error is `errors.of_kind?(:action, :invalid)`, with a readable message in `en` and `es`.
471
471
 
472
- Body rules don't change: an action goes with a message, it is never a message by itself. Both text and system messages can carry one. `edit!` keeps the action. **A tombstone shows neither the body nor the button**: soft deletion clears the action, and so does a moderator's `remove_reported_field!`, because it goes through the same tombstone. The inbox preview still shows the body.
472
+ Body rules don't change: an action goes with a message, it is never a message by itself. Both text and system messages can carry one. `edit!` keeps the action. **A tombstone shows neither the body nor the button**: soft deletion clears the action, and so does a moderator's `remove_reported_field!`, because it goes through the same tombstone. The inbox preview reads “Message deleted”, as for any tombstone.
473
473
 
474
474
  **Only your Ruby code sets an action.** The composer can't: `Chats::MessagesController` permits `body` and `files` only, the same as with `author:`. Restyle the button with `.chats-message__action` (eject `chats/messages/_action.html.erb` to change its markup). It uses your `--chats-accent` / `--chats-accent-contrast` colours: accent-filled under someone else's message or a system note, reversed inside your own accent-coloured bubble.
475
475
 
@@ -82,6 +82,7 @@ en:
82
82
  base:
83
83
  locked: "This conversation is closed."
84
84
  blocked: "You can't message this person."
85
+ not_allowed: "You can't message this person right now."
85
86
  action:
86
87
  invalid: "needs a label of up to %{count} characters and a link to an http(s) address or to a path on this site that starts with /"
87
88
  author:
@@ -82,6 +82,7 @@ es:
82
82
  base:
83
83
  locked: "Esta conversación está cerrada."
84
84
  blocked: "No puedes enviar mensajes a esta persona."
85
+ not_allowed: "Ahora mismo no puedes enviar mensajes a esta persona."
85
86
  action:
86
87
  invalid: "necesita un texto de hasta %{count} caracteres y un enlace a una dirección http(s) o a una ruta de este sitio que empiece por /"
87
88
  author:
data/lib/chats/action.rb CHANGED
@@ -40,8 +40,23 @@ module Chats
40
40
  # URI, a symbol label — all read back as the String the column holds.
41
41
  # nil stays nil so a missing one is a validation error, not a "" that
42
42
  # looks present.
43
+ #
44
+ # Each is a frozen, PLAIN String copy. `Data` freezes the action, not the
45
+ # strings it was handed, so without the copy `action.url.replace(
46
+ # "javascript:…")` would rewrite a validated action in place — and so
47
+ # would the caller mutating the string they passed in. Plain, because
48
+ # `String.new` drops an html_safe mark: a label that arrived as a
49
+ # SafeBuffer (a host's `t("…_html")`) would otherwise render unescaped
50
+ # in the button. An action is a value: what the validator saw is what
51
+ # the bubble renders, and the bubble escapes it like any other content.
43
52
  def initialize(label:, url:)
44
- super(label: label&.to_s, url: url&.to_s)
53
+ super(label: plain(label), url: plain(url))
54
+ end
55
+
56
+ private
57
+
58
+ def plain(value)
59
+ String.new(value.to_s).freeze unless value.nil?
45
60
  end
46
61
  end
47
62
 
@@ -120,10 +120,10 @@ module Chats
120
120
  target: "chats_unread_badge",
121
121
  partial: BADGE_PARTIAL,
122
122
  # Counted at broadcast-enqueue time: the badge is advisory UI; a
123
- # count that's seconds stale self-heals on the next event.
124
- # reorder(nil): COUNT(DISTINCT) + the inbox's COALESCE order would
125
- # error on PostgreSQL.
126
- locals: { count: Chats::Conversation.inbox_for(messager).unread_by(messager).reorder(nil).distinct.count }
123
+ # count that's seconds stale self-heals on the next event. The
124
+ # SAME count the page renders (`chats_unread_badge`), so what the
125
+ # inbox scope hides never lights the badge.
126
+ locals: { count: messager.unread_chats_count }
127
127
  )
128
128
  end
129
129
 
data/lib/chats/inbox.rb CHANGED
@@ -115,15 +115,12 @@ module Chats
115
115
  Chats.config.inbox_limit
116
116
  end
117
117
 
118
- # Memoized so `config.inbox_scope` is consulted ONCE per inbox for the
119
- # row query, however many legs it is split into (the stack aggregates
120
- # apply it separately, to their own relation).
118
+ # The viewer's own inbox relation (`Messager#chats`: `inbox_for` with
119
+ # `config.inbox_scope` applied, and whatever the host's model adds on
120
+ # top), memoized so it is built ONCE per inbox however many legs the
121
+ # row query is split into (the stack aggregates build their own).
121
122
  def base_relation
122
- @base_relation ||= begin
123
- relation = Chats::Conversation.inbox_for(viewer)
124
- .includes(:last_message, :subject, participants: :messager)
125
- Chats.config.inbox_scope.call(relation, viewer) || relation
126
- end
123
+ @base_relation ||= viewer.chats.includes(:last_message, :subject, participants: :messager)
127
124
  end
128
125
 
129
126
  # The polymorphic types worth splitting out. Empty when nothing stacks
@@ -244,12 +241,11 @@ module Chats
244
241
  # aggregates — independent of how deep the stack is, and of how much of
245
242
  # it we loaded.
246
243
  def stack_totals(messager)
247
- scope = Chats::Conversation.inbox_for(viewer).reorder(nil).direct.where(
244
+ scope = viewer.chats.reorder(nil).direct.where(
248
245
  id: Chats::Participant.select(:conversation_id).where(
249
246
  messager_type: messager.class.polymorphic_name, messager_id: messager.id
250
247
  )
251
248
  )
252
- scope = Chats.config.inbox_scope.call(scope, viewer) || scope
253
249
 
254
250
  {
255
251
  open: scope.distinct.count,
@@ -176,9 +176,17 @@ module Chats
176
176
  end
177
177
 
178
178
  # The inbox: every active conversation, blocked counterparts hidden,
179
- # newest activity first. A relation — chain `.limit`, `.includes`, etc.
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.
180
187
  def chats
181
- Chats::Conversation.inbox_for(self)
188
+ relation = Chats::Conversation.inbox_for(self)
189
+ Chats.config.inbox_scope.call(relation, self) || relation
182
190
  end
183
191
 
184
192
  # Number of conversations with unread messages (the WhatsApp-style nav
@@ -40,6 +40,8 @@ module Chats
40
40
  ACTION_LABEL_LIMIT = 80
41
41
  ACTION_URL_LIMIT = 2048
42
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
43
45
  ACTION_URL_PORTS = (1..65_535)
44
46
 
45
47
  belongs_to :conversation,
@@ -113,13 +115,15 @@ module Chats
113
115
  validate :body_or_files_required
114
116
  validate :body_must_fit_length_limit
115
117
  validate :sender_must_be_active_participant, on: :create
116
- validate :sender_must_not_be_blocked, on: :create
117
118
  validate :conversation_must_not_be_locked, on: :create
118
119
  validate :author_must_be_persisted
119
120
  # Checked at the write that SETS it, not on every save: an edit must
120
121
  # never fail over an action nobody touched.
121
122
  validate :action_must_be_well_formed, if: :will_save_change_to_action?
122
123
  validate :files_must_be_allowed
124
+ # Last on purpose: the host's policy is asked only of a send that is
125
+ # otherwise valid (see the method).
126
+ validate :sender_must_still_be_allowed, on: :create
123
127
 
124
128
  after_create :register_on_conversation
125
129
  after_create_commit :broadcast_created
@@ -335,15 +339,37 @@ module Chats
335
339
  errors.add(:sender, :not_a_participant) unless conversation.participant?(sender)
336
340
  end
337
341
 
338
- # Block enforcement at the WRITE, not just at conversation creation: a
339
- # block placed mid-conversation must stop the very next send. Direct
340
- # threads only — see Conversation.excluding_blocked_for for the group
341
- # rationale.
342
- def sender_must_not_be_blocked
343
- return if system? || sender.nil? || conversation.nil? || !conversation.direct?
342
+ # Who may talk to whom is decided at the WRITE, not just when the
343
+ # conversation was opened: a block placed mid-conversation stops the very
344
+ # next send, and so does the host's `can_message` policy turning false —
345
+ # an account banned, a listing closed, a membership ended. The pair was
346
+ # allowed when the thread began; what matters is whether it still is.
347
+ # Direct threads only — see Conversation.excluding_blocked_for for the
348
+ # group rationale. Two errors, because they read differently to the
349
+ # sender: `:blocked` (either side blocked the other) and `:not_allowed`
350
+ # (the policy said no).
351
+ #
352
+ # Asked last, and only of a send that is otherwise valid: a stranger to
353
+ # the conversation, a locked thread or an empty body is refused before
354
+ # the host's policy runs, so the policy is never consulted about a
355
+ # message that could not be sent anyway (it may be expensive, and it
356
+ # runs on every direct send now). A policy must not raise: an
357
+ # exception escapes the send like any other host error.
358
+ #
359
+ # A direct thread with nobody on the other side — the counterpart's
360
+ # account was destroyed and its seat went with it — takes no messages:
361
+ # there is no one the policy could allow.
362
+ def sender_must_still_be_allowed
363
+ return if system? || sender.nil? || conversation.nil? || !conversation.direct? || errors.any?
344
364
 
345
365
  other = conversation.other_participants(sender).first&.messager
346
- errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
366
+ if other.nil?
367
+ errors.add(:base, :not_allowed)
368
+ elsif Chats.blocked_between?(sender, other)
369
+ errors.add(:base, :blocked)
370
+ elsif !Chats.config.can_message.call(sender, other)
371
+ errors.add(:base, :not_allowed)
372
+ end
347
373
  end
348
374
 
349
375
  # Authorship is a signature, not a conversation seat. A host may keep
@@ -382,6 +408,9 @@ module Chats
382
408
  url = action.url
383
409
  return false if url.blank? || url.length > ACTION_URL_LIMIT
384
410
  return false if url.start_with?("//", "/\\")
411
+ # `https://host:` parses as the default port, so an empty port suffix
412
+ # would pass the range check below; a named port must have digits.
413
+ return false if url.match?(EMPTY_PORT)
385
414
 
386
415
  uri = URI.parse(url)
387
416
  if uri.scheme
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.4.0"
4
+ VERSION = "0.4.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez