chats 0.4.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: baea21fb7f7a5ce0c50ec662b84fbef24adaa4ceb0c12a876de29891f88a2419
4
- data.tar.gz: c199fc0c645bd667b7672e1ca0ea7257eb0e9cf05179c576f606fcf784b9feb0
3
+ metadata.gz: '001542179fe490a6f8743099b6b31a2d9cff7cf43a28b982c0cac932b8c06bd1'
4
+ data.tar.gz: 29720d94ecd7302ae2adcb17f9a81b6fbc6f0365d900e5773655250e14ea9bb9
5
5
  SHA512:
6
- metadata.gz: 7739f616cf9e555ea1ed2e6321937bc6698d9e08733ee6c496d95ed41e2331694b5340ab5be641ac2e73875a17c4a4859bfa09e01cb0b3984a5b4fa340dcceb1
7
- data.tar.gz: 712ac6d49724af1505807c3aaadd4f10fd52c8fec9653efbb9d4a89843be6fa0b2ec680873e240cc4c2bc139048cb5796b1165fced263992dc13d1499d2da0fc
6
+ metadata.gz: 3d9f25316a4832204fb285ea6547fe34a5c7f938c148307bef311a3311ed770401329b60e020031d7468b200ecf1170294cb7edce6dfbebb5d8c386052aa1adf
7
+ data.tar.gz: d7f28cf57770b2ad517299f56d431ea11fcc6f33b1886c36a815cce6de81dec983082cd16eb74dfd3528bb966af8feb277cb6ff286d7c89d395f934a12737d3c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ 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.1] - 2026-10-17
8
+
9
+ ### Fixed
10
+ - **An action's strings are frozen copies.** `Data` freezes the action but
11
+ not the strings it was handed, so `action.url.replace("javascript:…")`,
12
+ or the caller mutating the string they passed in, could rewrite a
13
+ validated action in memory before the bubble rendered it. `Chats::Action`
14
+ now keeps a frozen copy of both members: what the validator saw is what
15
+ renders.
16
+ - **An empty port suffix is refused.** `https://host:/x` parses as the
17
+ default port and slipped past the 1..65535 rule; a named port must have
18
+ digits.
19
+ - **An action's label is a plain String.** A label handed over as an
20
+ html_safe string (a host's `t("…_html")`) kept the mark through the copy
21
+ and rendered unescaped in the button. The copy is now a plain `String`,
22
+ so the bubble escapes it like any other content.
23
+ - **`config.inbox_scope` is one rule, applied once.** `Messager#chats` now
24
+ applies it, so the inbox relation, `unread_chats_count`, the inbox screen
25
+ and the live badge all agree. The badge broadcast used to count through
26
+ the raw `inbox_for` relation and could light up for a conversation the
27
+ inbox would never show (a host scoping game records out of the inbox saw
28
+ the badge count them). `Chats::Inbox` builds on `viewer.chats`, so a
29
+ host's own override of `chats` reaches the inbox screen too.
30
+ - The README said the inbox preview still shows a soft-deleted message's
31
+ body; it reads "Message deleted", as for any tombstone.
32
+
7
33
  ## [0.4.0] - 2026-10-17
8
34
 
9
35
  Action buttons. A support desk writes "Your invoice is ready" and wants a
data/README.md CHANGED
@@ -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
 
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,
@@ -382,6 +384,9 @@ module Chats
382
384
  url = action.url
383
385
  return false if url.blank? || url.length > ACTION_URL_LIMIT
384
386
  return false if url.start_with?("//", "/\\")
387
+ # `https://host:` parses as the default port, so an empty port suffix
388
+ # would pass the range check below; a named port must have digits.
389
+ return false if url.match?(EMPTY_PORT)
385
390
 
386
391
  uri = URI.parse(url)
387
392
  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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez