chats 0.3.1 → 0.4.0

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.
@@ -14,6 +14,22 @@
14
14
  and on return-to-visible. It needs to wrap the <turbo-cable-stream-source>
15
15
  that `turbo_stream_from` renders so it can observe its `connected`
16
16
  attribute as the heartbeat. %>
17
+
18
+ <%# The OTHER inbox-staleness case, distinct from a missed broadcast: a Turbo
19
+ RESTORATION visit (browser back / Hotwire Native stack pop) serves the
20
+ inbox's cached snapshot with no GET. Open a chat from a profile, send a
21
+ message, tap back — the snapshot cached before the chat existed comes back
22
+ and the new conversation is missing until you navigate away and return.
23
+ The reconciler above can't help (it only fires on reconnect / visibility,
24
+ not on a restore visit). `turbo-cache-control: no-cache` makes the inbox
25
+ uncacheable, so a restore visit re-fetches from the network and is always
26
+ fresh — on the web AND in Hotwire Native, whose back-pop issues a Turbo
27
+ `.restore` visit that honors this. Requires the host layout to
28
+ `yield :head` (the Rails convention); harmless no-op otherwise.
29
+ https://turbo.hotwired.dev/handbook/building#opting-out-of-caching %>
30
+ <% content_for :head do %>
31
+ <meta name="turbo-cache-control" content="no-cache">
32
+ <% end %>
17
33
  <%= chats_styles %>
18
34
  <div data-controller="chats--refresh-inbox">
19
35
  <%= turbo_stream_from chats_current_messager, :chats_inbox %>
@@ -0,0 +1,15 @@
1
+ <%# A message's featured button (Chats::Action): the one place it takes you,
2
+ under the words it came with. Rendered inside the bubble, so it is part
3
+ of the single viewer-agnostic broadcast render AND of the clone the
4
+ long-press popup lifts — which is why it is a plain link and never a
5
+ form: a form would need a per-viewer CSRF token baked into a render
6
+ every subscriber shares. An action is a destination, not a mutation.
7
+
8
+ data-turbo-frame="_top": older pages of a thread arrive inside lazy
9
+ `chats_page_…` turbo-frames, and a link inside a frame navigates the
10
+ FRAME. _top makes it navigate the page, wherever the bubble landed.
11
+
12
+ The url was validated at the write (Chats::Message#
13
+ action_must_be_well_formed): http(s) or a path on this site, never
14
+ `javascript:` or protocol-relative. %>
15
+ <%= link_to action.label, action.url, class: "chats-message__action", data: { turbo_frame: "_top" } %>
@@ -1,71 +1,85 @@
1
1
  <%# The composer. Submits as a Turbo Stream (the sender's bubble appends from
2
2
  the response — instant, no cable round-trip needed); the chats--composer
3
3
  controller adds autosize, desktop Enter-to-send, throttled typing pings,
4
- and reset-on-success. Plain form POST still works with JS disabled. %>
5
- <%# id: the swap target — a conversation that locks while this form is open
6
- gets the locked notice rendered straight over it (see
7
- chats/messages/locked.turbo_stream.erb). %>
8
- <%= form_with model: Chats::Message.new,
9
- url: conversation_messages_path(conversation),
10
- id: dom_id(conversation, :composer),
11
- class: "chats-composer",
12
- data: {
13
- controller: "chats--composer",
14
- "chats--composer-typing-url-value": (typing_conversation_path(conversation) if Chats.config.typing_indicators),
15
- action: "turbo:submit-end->chats--composer#submitted chats:edit-message@window->chats--composer#beginEdit"
16
- } do |form| %>
4
+ and reset-on-success. Plain form POST still works with JS disabled.
17
5
 
18
- <div id="<%= dom_id(conversation, :composer_errors) %>" class="chats-composer__errors"></div>
6
+ The outer div is the DOCK — the form plus the host's composer_actions.
7
+ It carries the swap id: a conversation that locks while the composer is
8
+ open gets the locked notice rendered straight over the whole dock
9
+ (chats/messages/locked.turbo_stream.erb), so the host's buttons go with
10
+ it. `.chats-composer` styles the dock; the form is `.chats-composer__form`. %>
11
+ <div id="<%= dom_id(conversation, :composer) %>" class="chats-composer">
12
+ <%# Slot: the host's own buttons by the composer ("Create a game", "Send my
13
+ location"). OUTSIDE the form on purpose: host buttons are usually
14
+ button_to forms, and a form inside a form is invalid HTML — the browser
15
+ drops the inner one and its button submits THIS form instead. %>
16
+ <% if chats_slot?(:composer_actions) %>
17
+ <div class="chats-composer__actions">
18
+ <%= chats_slot :composer_actions, conversation: conversation %>
19
+ </div>
20
+ <% end %>
19
21
 
20
- <%# The edit cue (Telegram's flow): long-press → Editar loads the message
21
- into this same form; this quote-style bar (left border, bold label,
22
- the original trimmed to one line) is the signal that send = save.
23
- The ✕ cancels back to compose mode. %>
24
- <div class="chats-composer__edit" data-chats--composer-target="editBar" hidden>
25
- <div class="chats-composer__edit-quote">
26
- <span class="chats-composer__edit-label"><%= t("chats.composer.editing") %></span>
27
- <span class="chats-composer__edit-preview" data-chats--composer-target="editPreview"></span>
28
- </div>
29
- <button type="button"
30
- class="chats-composer__edit-cancel"
31
- aria-label="<%= t("chats.composer.cancel_edit") %>"
32
- data-action="chats--composer#cancelEdit">×</button>
33
- </div>
22
+ <%= form_with model: Chats::Message.new,
23
+ url: conversation_messages_path(conversation),
24
+ class: "chats-composer__form",
25
+ data: {
26
+ controller: "chats--composer",
27
+ "chats--composer-typing-url-value": (typing_conversation_path(conversation) if Chats.config.typing_indicators),
28
+ action: "turbo:submit-end->chats--composer#submitted chats:edit-message@window->chats--composer#beginEdit"
29
+ } do |form| %>
34
30
 
35
- <%# Selected-attachment previews: thumbnails with per-file remove, rendered
36
- client-side from the picker selection (object URLs — nothing uploads
37
- until send). This is THE feedback that files are about to go out; a
38
- counter chip alone reads as noise. %>
39
- <% if Chats.config.attachments %>
40
- <div class="chats-composer__previews" data-chats--composer-target="previews" hidden></div>
41
- <% end %>
31
+ <div id="<%= dom_id(conversation, :composer_errors) %>" class="chats-composer__errors"></div>
32
+
33
+ <%# The edit cue (Telegram's flow): long-press → Editar loads the message
34
+ into this same form; this quote-style bar (left border, bold label,
35
+ the original trimmed to one line) is the signal that send = save.
36
+ The ✕ cancels back to compose mode. %>
37
+ <div class="chats-composer__edit" data-chats--composer-target="editBar" hidden>
38
+ <div class="chats-composer__edit-quote">
39
+ <span class="chats-composer__edit-label"><%= t("chats.composer.editing") %></span>
40
+ <span class="chats-composer__edit-preview" data-chats--composer-target="editPreview"></span>
41
+ </div>
42
+ <button type="button"
43
+ class="chats-composer__edit-cancel"
44
+ aria-label="<%= t("chats.composer.cancel_edit") %>"
45
+ data-action="chats--composer#cancelEdit">×</button>
46
+ </div>
42
47
 
43
- <div class="chats-composer__bar">
48
+ <%# Selected-attachment previews: thumbnails with per-file remove, rendered
49
+ client-side from the picker selection (object URLs — nothing uploads
50
+ until send). This is THE feedback that files are about to go out; a
51
+ counter chip alone reads as noise. %>
44
52
  <% if Chats.config.attachments %>
45
- <label class="chats-composer__attach" aria-label="<%= t("chats.composer.attach") %>">
46
- <%= form.file_field :files, multiple: true, hidden: true,
47
- accept: (Chats.config.attachments == :images ? "image/*" : nil),
48
- data: { "chats--composer-target": "files", action: "change->chats--composer#filesChanged" } %>
49
- <span aria-hidden="true">📎</span>
50
- <span class="chats-composer__file-count" data-chats--composer-target="fileCount" hidden></span>
51
- </label>
53
+ <div class="chats-composer__previews" data-chats--composer-target="previews" hidden></div>
52
54
  <% end %>
53
55
 
54
- <%= form.text_area :body,
55
- rows: 1,
56
- placeholder: t("chats.composer.placeholder"),
57
- class: "chats-composer__input",
58
- autocomplete: "off",
59
- data: {
60
- "chats--composer-target": "input",
61
- action: "input->chats--composer#typed keydown.enter->chats--composer#enterSend"
62
- } %>
56
+ <div class="chats-composer__bar">
57
+ <% if Chats.config.attachments %>
58
+ <label class="chats-composer__attach" aria-label="<%= t("chats.composer.attach") %>">
59
+ <%= form.file_field :files, multiple: true, hidden: true,
60
+ accept: (Chats.config.attachments == :images ? "image/*" : nil),
61
+ data: { "chats--composer-target": "files", action: "change->chats--composer#filesChanged" } %>
62
+ <span aria-hidden="true">📎</span>
63
+ <span class="chats-composer__file-count" data-chats--composer-target="fileCount" hidden></span>
64
+ </label>
65
+ <% end %>
63
66
 
64
- <%# pointerdown/mousedown keepFocus: keeps the textarea focused (and the
65
- mobile keyboard up) through the send tap — see composer controller. %>
66
- <%= form.button type: :submit, class: "chats-composer__send", "aria-label": t("chats.composer.send"),
67
- data: { action: "pointerdown->chats--composer#keepFocus mousedown->chats--composer#keepFocus" } do %>
68
- <span aria-hidden="true">➤</span>
69
- <% end %>
70
- </div>
71
- <% end %>
67
+ <%= form.text_area :body,
68
+ rows: 1,
69
+ placeholder: t("chats.composer.placeholder"),
70
+ class: "chats-composer__input",
71
+ autocomplete: "off",
72
+ data: {
73
+ "chats--composer-target": "input",
74
+ action: "input->chats--composer#typed keydown.enter->chats--composer#enterSend"
75
+ } %>
76
+
77
+ <%# pointerdown/mousedown keepFocus: keeps the textarea focused (and the
78
+ mobile keyboard up) through the send tap — see composer controller. %>
79
+ <%= form.button type: :submit, class: "chats-composer__send", "aria-label": t("chats.composer.send"),
80
+ data: { action: "pointerdown->chats--composer#keepFocus mousedown->chats--composer#keepFocus" } do %>
81
+ <span aria-hidden="true">➤</span>
82
+ <% end %>
83
+ </div>
84
+ <% end %>
85
+ </div>
@@ -23,7 +23,10 @@
23
23
  data-updated-at-ms="<%= (message.updated_at.to_f * 1000).ceil %>">
24
24
 
25
25
  <% if message.system? %>
26
- <div class="chats-message__system"><%= message.body %></div>
26
+ <div class="chats-message__system<%= " chats-message__system--with-action" if message.action? %>">
27
+ <%= message.body %>
28
+ <%= render "chats/messages/action", action: message.action if message.action? %>
29
+ </div>
27
30
  <% else %>
28
31
  <div class="chats-message__content">
29
32
  <% if message.conversation.group? && message.sender %>
@@ -74,6 +77,8 @@
74
77
  <% end %>
75
78
  </div>
76
79
  <% end %>
80
+
81
+ <%= render "chats/messages/action", action: message.action if message.action? %>
77
82
  <% end %>
78
83
 
79
84
  <%# Signature: who WROTE this, when that isn't who it was sent
data/chats-0.3.1.gem ADDED
Binary file
@@ -64,6 +64,9 @@ en:
64
64
  unmuted: "Conversation unmuted."
65
65
 
66
66
  activerecord:
67
+ attributes:
68
+ chats/message:
69
+ action: "Action"
67
70
  errors:
68
71
  models:
69
72
  chats/conversation:
@@ -79,6 +82,8 @@ en:
79
82
  base:
80
83
  locked: "This conversation is closed."
81
84
  blocked: "You can't message this person."
85
+ action:
86
+ 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 /"
82
87
  author:
83
88
  not_a_messager: "must be a messager (acts_as_messager)"
84
89
  sender:
@@ -64,6 +64,9 @@ es:
64
64
  unmuted: "Notificaciones activadas."
65
65
 
66
66
  activerecord:
67
+ attributes:
68
+ chats/message:
69
+ action: "Acción"
67
70
  errors:
68
71
  models:
69
72
  chats/conversation:
@@ -79,6 +82,8 @@ es:
79
82
  base:
80
83
  locked: "Esta conversación está cerrada."
81
84
  blocked: "No puedes enviar mensajes a esta persona."
85
+ action:
86
+ 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 /"
82
87
  author:
83
88
  not_a_messager: "debe ser un mensajero (acts_as_messager)"
84
89
  sender:
Binary file
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # The one featured button a message can carry: a label and the place it
5
+ # takes you.
6
+ #
7
+ # alice.message!(bob, "Let's play!", action: { label: "Join my room", url: "/play/friend/abcd" })
8
+ #
9
+ # message.action # => #<data Chats::Action label="Join my room", url="/play/friend/abcd">
10
+ # message.action.label # => "Join my room"
11
+ # message.action.to_h # => { label: "Join my room", url: "/play/friend/abcd" }
12
+ #
13
+ # An immutable value, compared by value: two actions with the same label
14
+ # and url are the same action. It knows nothing about what makes one safe
15
+ # to render — Chats::Message validates that at the write, where a bad one
16
+ # can fail loudly instead of rendering a dead or dangerous link.
17
+ Action = Data.define(:label, :url) do
18
+ # Whatever a host hands `action:` — nil, a Hash (string or symbol keys),
19
+ # or an Action — as an Action, or nil.
20
+ #
21
+ # Unknown keys raise rather than vanish: `{ label:, url:, method: :post }`
22
+ # silently becoming a GET link is exactly the surprise this refuses.
23
+ def self.wrap(value)
24
+ case value
25
+ when nil, self then value
26
+ when Hash
27
+ attributes = value.to_h { |key, attribute| [key.to_s, attribute] }
28
+ unknown = attributes.keys - members.map(&:to_s)
29
+ if unknown.any?
30
+ raise ArgumentError, "unknown action keys: #{unknown.join(", ")} (an action is a label and a url)"
31
+ end
32
+
33
+ new(label: attributes["label"], url: attributes["url"])
34
+ else
35
+ raise ArgumentError, "an action is a Hash with a label and a url, or a Chats::Action — got #{value.inspect}"
36
+ end
37
+ end
38
+
39
+ # Both are text by the time they're stored: a path helper's String, a
40
+ # URI, a symbol label — all read back as the String the column holds.
41
+ # nil stays nil so a missing one is a validation error, not a "" that
42
+ # looks present.
43
+ def initialize(label:, url:)
44
+ super(label: label&.to_s, url: url&.to_s)
45
+ end
46
+ end
47
+
48
+ class Action
49
+ # How `chats_messages.action` (json/jsonb) reads and writes: an Action
50
+ # or nil on the Ruby side, `{"label":…,"url":…}` in the column. Every
51
+ # assignment goes through Action.wrap, so a Hash, an Action and nil all
52
+ # work, and anything else raises right where it was assigned.
53
+ class Type < ActiveRecord::Type::Json
54
+ def cast(value)
55
+ Action.wrap(value)
56
+ end
57
+
58
+ # Reads only the two keys an action has, so a row written by a later
59
+ # version with more of them still loads instead of raising.
60
+ def deserialize(value)
61
+ stored = super
62
+ Action.wrap(stored.slice("label", "url")) if stored.is_a?(Hash)
63
+ end
64
+
65
+ def serialize(value)
66
+ super(Action.wrap(value)&.to_h)
67
+ end
68
+ end
69
+ end
70
+ end
data/lib/chats/engine.rb CHANGED
@@ -30,7 +30,7 @@ module Chats
30
30
  CHATS_LIB = File.expand_path("chats", LIB_ROOT)
31
31
 
32
32
  ZEITWERK_IGNORED = %w[
33
- version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb
33
+ version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb send_rate_limited.rb
34
34
  ].freeze
35
35
 
36
36
  initializer "chats.autoload", before: :set_autoload_paths do
data/lib/chats/inbox.rb CHANGED
@@ -151,10 +151,13 @@ module Chats
151
151
  else
152
152
  stacked = Chats::Conversation.direct.where(id: stacked_seats)
153
153
  @ungrouped_relation = base_relation.where.not(id: stacked).limit(limit)
154
- # Ordered by recency and limited like the other leg, which also makes
155
- # the FIRST conversation of each counterpart that counterpart's
156
- # freshest — that's the one the stacked row previews.
157
- @stacked = apply_search(base_relation.direct.where(id: stacked_seats).limit(limit))
154
+ recent = base_relation.direct.where(id: stacked_seats).except(:includes).select(:id).limit(limit)
155
+ # Preserve the recent search window, plus the freshest conversation
156
+ # of each counterpart. A busy stack cannot consume another stack's
157
+ # row; at most twice the row limit is materialized.
158
+ candidates = base_relation.where(id: limited_ids(recent))
159
+ .or(base_relation.where(id: limited_ids(stack_representatives)))
160
+ @stacked = apply_search(candidates)
158
161
  end
159
162
 
160
163
  @ungrouped = apply_search(@ungrouped_relation)
@@ -163,6 +166,29 @@ module Chats
163
166
  @flat = @stacked.empty? && query.nil? ? @ungrouped_relation : @ungrouped + @stacked
164
167
  end
165
168
 
169
+ # MySQL rejects LIMIT directly in an IN subquery. A derived table keeps
170
+ # the bounded selection in SQL and works on all three supported adapters.
171
+ def limited_ids(relation)
172
+ Chats::Conversation.from(relation, :limited_conversations).select(:id)
173
+ end
174
+
175
+ # ROW_NUMBER works on every supported adapter (PostgreSQL, SQLite and
176
+ # MySQL 8). Ranking in SQL avoids loading an entire busy desk's history.
177
+ def stack_representatives
178
+ activity = "COALESCE(chats_conversations.last_message_at, chats_conversations.created_at)"
179
+ ranking = "ROW_NUMBER() OVER (PARTITION BY stack_seats.messager_type, stack_seats.messager_id " \
180
+ "ORDER BY #{activity} DESC, chats_conversations.id DESC) AS stack_rank"
181
+ ranked = base_relation.direct.except(:includes, :order)
182
+ .joins("INNER JOIN chats_participants stack_seats " \
183
+ "ON stack_seats.conversation_id = chats_conversations.id")
184
+ .where(stack_seats: { messager_type: grouped_types })
185
+ .where.not(stack_seats: { messager_type: viewer.class.polymorphic_name,
186
+ messager_id: viewer.id })
187
+ .select("chats_conversations.id, #{activity} AS activity_at", ranking)
188
+ Chats::Conversation.from(ranked, :ranked_stacks).where("stack_rank = 1")
189
+ .order(Arel.sql("activity_at DESC, id DESC")).limit(limit).select(:id)
190
+ end
191
+
166
192
  # Only the direct threads shared with one counterpart. Direct only, by
167
193
  # design: a group that happens to include the desk is not part of the
168
194
  # desk's stack.
@@ -10,6 +10,8 @@ module Chats
10
10
  #
11
11
  # alice.message!(bob, "hola!") # DM in one line
12
12
  # alice.message!(conversation, "hola!") # or into a conversation
13
+ # alice.message!(bob, "Let's play!", # with a button under it
14
+ # action: { label: "Join my room", url: "/play/abcd" })
13
15
  #
14
16
  # alice.chats # her inbox, newest first
15
17
  # alice.unread_chats_count # for the nav badge
@@ -155,19 +157,20 @@ module Chats
155
157
  # sender stays the conversation identity. See Chats::Message#signed?.
156
158
  #
157
159
  # desk.message!(alice, "On it!", author: lucia)
158
- def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil)
159
- if author && !Chats.messager_class?(author.class)
160
- raise Chats::NotAllowedError,
161
- "author must be a messager (acts_as_messager), got #{author.class.name}"
162
- end
163
-
160
+ #
161
+ # `action:` is the one featured button under the words — a label and a
162
+ # url (see Chats::Action). It rides on a message the body rules already
163
+ # accept; an action alone is not a message.
164
+ #
165
+ # alice.message!(bob, "Let's play!", action: { label: "Join my room", url: room_path(room) })
166
+ def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil, action: nil)
164
167
  conversation =
165
168
  case target
166
169
  when Chats::Conversation then target
167
170
  else chat_with(target, about: about)
168
171
  end
169
172
 
170
- attributes = { sender: self, body: body, reply_to: reply_to, author: author }
173
+ attributes = { sender: self, body: body, reply_to: reply_to, author: author, action: action }
171
174
  attributes[:files] = files if files.present?
172
175
  conversation.messages.create!(**attributes)
173
176
  end
@@ -114,6 +114,8 @@ module Chats
114
114
  # mechanism. https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-create_or_find_by
115
115
  validate :groups_must_be_enabled, if: :group?
116
116
 
117
+ after_create_commit -> { Chats.notify(:conversation_created, conversation: self) }
118
+
117
119
  # --- Finding & creating ---------------------------------------------------
118
120
 
119
121
  class << self
@@ -133,21 +135,17 @@ module Chats
133
135
  raise Chats::BlockedError, "messagers are blocked" if Chats.blocked_between?(a, b)
134
136
  raise Chats::NotAllowedError, "policy forbids messaging" unless Chats.can_message?(a, b)
135
137
 
136
- conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c|
137
- c.kind = "direct"
138
- c.subject = about
138
+ transaction do
139
+ conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c|
140
+ c.kind = "direct"
141
+ c.subject = about
142
+ end
143
+ # Keep the roster in the creation transaction: the after-commit
144
+ # event must describe a complete conversation, including under an
145
+ # outer host transaction or a failed participant insertion.
146
+ [a, b].each { |messager| conversation.add_participant!(messager) }
147
+ conversation
139
148
  end
140
-
141
- # `create_or_find_by!` may have FOUND a conversation created a moment
142
- # ago by the other side — participants are ensured idempotently
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?
148
- [a, b].each { |messager| conversation.add_participant!(messager) }
149
- Chats.notify(:conversation_created, conversation: conversation) if created
150
- conversation
151
149
  end
152
150
 
153
151
  # Create a group conversation. +others+ excludes the creator (who joins
@@ -163,17 +161,12 @@ module Chats
163
161
  others = Array(others) - [creator]
164
162
  raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
165
163
 
166
- conversation = transaction do
164
+ transaction do
167
165
  created = create!(kind: "group", title: title, subject: about)
168
166
  created.add_participant!(creator, role: "owner")
169
167
  others.each { |messager| created.add_participant!(messager) }
170
168
  created
171
169
  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
177
170
  end
178
171
 
179
172
  # Deterministic identity for a direct pair (+ optional subject).
@@ -327,8 +320,13 @@ module Chats
327
320
  # cancelled", "Payment received" — rendered as a centered system note,
328
321
  # not a bubble. This is the stable entry point notification systems
329
322
  # (e.g. a Noticed delivery method) should call.
330
- def post_system_message!(body)
331
- messages.create!(kind: "system", body: body)
323
+ #
324
+ # `action:` puts a button under the note (see Chats::Action):
325
+ #
326
+ # conversation.post_system_message!("Your ride was cancelled",
327
+ # action: { label: "Book another", url: "/rides/new" })
328
+ def post_system_message!(body, action: nil)
329
+ messages.create!(kind: "system", body: body, action: action)
332
330
  end
333
331
 
334
332
  # --- Read state -----------------------------------------------------------
@@ -17,6 +17,12 @@ module Chats
17
17
  # content AT REPORT TIME, so removing the body later never destroys the
18
18
  # evidence a moderator needs.
19
19
  #
20
+ # == Actions
21
+ #
22
+ # A message may carry ONE featured button — a label and a url, set by the
23
+ # host from Ruby ("Your invoice is ready" → *Pay now*). See Chats::Action
24
+ # for the value and #action_must_be_well_formed for what a safe one is.
25
+ #
20
26
  # == Real-time
21
27
  #
22
28
  # All Turbo Stream fan-out lives in Chats::Broadcasts (one place to read
@@ -28,6 +34,14 @@ module Chats
28
34
 
29
35
  KINDS = %w[text system].freeze
30
36
 
37
+ # The shape of a renderable action (see #action_must_be_well_formed): a
38
+ # label short enough to be a button, a url short enough to be a link,
39
+ # and only the schemes a browser navigates to safely.
40
+ ACTION_LABEL_LIMIT = 80
41
+ ACTION_URL_LIMIT = 2048
42
+ ACTION_URL_SCHEMES = %w[http https].freeze
43
+ ACTION_URL_PORTS = (1..65_535)
44
+
31
45
  belongs_to :conversation,
32
46
  class_name: "Chats::Conversation",
33
47
  inverse_of: :messages,
@@ -56,6 +70,11 @@ module Chats
56
70
  # see the migration template's json_column_default).
57
71
  attribute :metadata, default: -> { {} }
58
72
 
73
+ # The featured button (Chats::Action), or nil. Its own column rather than
74
+ # a key in `metadata`, which stays the host's bag: an action is a
75
+ # contract the bundled bubble renders, so the gem must own where it lives.
76
+ attribute :action, Chats::Action::Type.new
77
+
59
78
  scope :recent_first, -> { order(created_at: :desc, id: :desc) }
60
79
  scope :oldest_first, -> { order(created_at: :asc, id: :asc) }
61
80
  scope :visible, -> { where(deleted_at: nil) }
@@ -96,7 +115,10 @@ module Chats
96
115
  validate :sender_must_be_active_participant, on: :create
97
116
  validate :sender_must_not_be_blocked, on: :create
98
117
  validate :conversation_must_not_be_locked, on: :create
99
- validate :author_must_be_a_messager
118
+ validate :author_must_be_persisted
119
+ # Checked at the write that SETS it, not on every save: an edit must
120
+ # never fail over an action nobody touched.
121
+ validate :action_must_be_well_formed, if: :will_save_change_to_action?
100
122
  validate :files_must_be_allowed
101
123
 
102
124
  after_create :register_on_conversation
@@ -110,6 +132,14 @@ module Chats
110
132
  def text? = kind == "text"
111
133
  def deleted? = deleted_at.present?
112
134
  def edited? = edited_at.present?
135
+ def action? = !action.nil?
136
+
137
+ # Wraps on the way in, not lazily on first read (ActiveModel casts user
138
+ # input when it is read): `message.action = "/pay"` raises right here,
139
+ # at the line that made the mistake.
140
+ def action=(value)
141
+ super(Chats::Action.wrap(value))
142
+ end
113
143
 
114
144
  def sender_key
115
145
  sender && Chats.messager_key(sender)
@@ -160,6 +190,10 @@ module Chats
160
190
  # Delete according to `config.deletion` (see class comment). Returns
161
191
  # false when deletion is disabled.
162
192
  #
193
+ # A tombstone shows neither the words nor the button that came with
194
+ # them, so the soft path clears the action too — and moderation, which
195
+ # removes content through here, takes the link down with the body.
196
+ #
163
197
  # `enforce_lock: false` is for MODERATION only (see
164
198
  # #remove_reported_field!): a product lock must never shield reported
165
199
  # content from removal.
@@ -170,7 +204,7 @@ module Chats
170
204
  when :soft
171
205
  transaction do
172
206
  files.purge_later if respond_to?(:files) && files.attached?
173
- update!(body: nil, deleted_at: Time.current)
207
+ update!(body: nil, action: nil, deleted_at: Time.current)
174
208
  end
175
209
  true
176
210
  when :hard
@@ -312,13 +346,52 @@ module Chats
312
346
  errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
313
347
  end
314
348
 
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)
349
+ # Authorship is a signature, not a conversation seat. A host may keep
350
+ # staff in a separate model without giving them messaging capabilities.
351
+ # Never implicitly create that identity while sending a message.
352
+ def author_must_be_persisted
353
+ return if author.nil? || author.persisted?
354
+
355
+ errors.add(:author, :invalid)
356
+ end
357
+
358
+ # What a button the bubble renders may be: a short label, and a link to
359
+ # an http(s) URL or a path on this site. The host is trusted, but a bad
360
+ # action has to fail HERE, loudly, rather than render a dead link or a
361
+ # `javascript:` one.
362
+ #
363
+ # Two layers, because a browser and URI.parse disagree about where a url
364
+ # goes. First, a string check BEFORE parsing: nothing starting with `//`
365
+ # or `/\` is a path on this site. URI.parse reads `///evil.example/x`
366
+ # as a path with no host, yet a browser resolves it to
367
+ # https://evil.example/x, and treats `/\` like `//`. Then the parse,
368
+ # which rejects whitespace, control characters and backslashes anywhere
369
+ # (`/<tab>/evil.example` is stripped into `//evil.example` by browsers),
370
+ # and decides scheme, host and port.
371
+ def action_must_be_well_formed
372
+ return if action.nil?
320
373
 
321
- errors.add(:author, :not_a_messager)
374
+ errors.add(:action, :invalid, count: ACTION_LABEL_LIMIT) unless action_label_fits? && action_url_safe?
375
+ end
376
+
377
+ def action_label_fits?
378
+ action.label.present? && action.label.length <= ACTION_LABEL_LIMIT
379
+ end
380
+
381
+ def action_url_safe?
382
+ url = action.url
383
+ return false if url.blank? || url.length > ACTION_URL_LIMIT
384
+ return false if url.start_with?("//", "/\\")
385
+
386
+ uri = URI.parse(url)
387
+ if uri.scheme
388
+ ACTION_URL_SCHEMES.include?(uri.scheme.downcase) && uri.host.present? && ACTION_URL_PORTS.cover?(uri.port)
389
+ else
390
+ # A path on this site: "/rides/new", never "//elsewhere.example".
391
+ url.start_with?("/") && uri.host.nil?
392
+ end
393
+ rescue URI::InvalidURIError
394
+ false
322
395
  end
323
396
 
324
397
  # The subject owns the conversation's openness (Chats::ChatSubject#