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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +2 -2
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/lib/chats/action.rb +16 -1
- data/lib/chats/broadcasts.rb +4 -4
- data/lib/chats/inbox.rb +6 -10
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/message.rb +37 -8
- data/lib/chats/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f2f76b973f012e2c86514932efaca3db25eaf8c70ed4709276d1f5f33d520de2
|
|
4
|
+
data.tar.gz: 2b88c68ad0d239a9ba87b5e907193924926a3926715ed39b62f844e2d899bf19
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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/config/locales/en.yml
CHANGED
|
@@ -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:
|
data/config/locales/es.yml
CHANGED
|
@@ -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
|
|
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
|
|
data/lib/chats/broadcasts.rb
CHANGED
|
@@ -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
|
-
#
|
|
125
|
-
#
|
|
126
|
-
locals: { 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
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
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 ||=
|
|
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 =
|
|
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 —
|
|
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
|
data/lib/chats/models/message.rb
CHANGED
|
@@ -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
|
-
#
|
|
339
|
-
# block placed mid-conversation
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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