chats 0.3.2 → 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.
@@ -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
@@ -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,85 @@
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
+ #
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.
52
+ def initialize(label:, url:)
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?
60
+ end
61
+ end
62
+
63
+ class Action
64
+ # How `chats_messages.action` (json/jsonb) reads and writes: an Action
65
+ # or nil on the Ruby side, `{"label":…,"url":…}` in the column. Every
66
+ # assignment goes through Action.wrap, so a Hash, an Action and nil all
67
+ # work, and anything else raises right where it was assigned.
68
+ class Type < ActiveRecord::Type::Json
69
+ def cast(value)
70
+ Action.wrap(value)
71
+ end
72
+
73
+ # Reads only the two keys an action has, so a row written by a later
74
+ # version with more of them still loads instead of raising.
75
+ def deserialize(value)
76
+ stored = super
77
+ Action.wrap(stored.slice("label", "url")) if stored.is_a?(Hash)
78
+ end
79
+
80
+ def serialize(value)
81
+ super(Action.wrap(value)&.to_h)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -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,
@@ -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,22 +157,36 @@ 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)
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)
159
167
  conversation =
160
168
  case target
161
169
  when Chats::Conversation then target
162
170
  else chat_with(target, about: about)
163
171
  end
164
172
 
165
- 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 }
166
174
  attributes[:files] = files if files.present?
167
175
  conversation.messages.create!(**attributes)
168
176
  end
169
177
 
170
178
  # The inbox: every active conversation, blocked counterparts hidden,
171
- # 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.
172
187
  def chats
173
- Chats::Conversation.inbox_for(self)
188
+ relation = Chats::Conversation.inbox_for(self)
189
+ Chats.config.inbox_scope.call(relation, self) || relation
174
190
  end
175
191
 
176
192
  # Number of conversations with unread messages (the WhatsApp-style nav
@@ -320,8 +320,13 @@ module Chats
320
320
  # cancelled", "Payment received" — rendered as a centered system note,
321
321
  # not a bubble. This is the stable entry point notification systems
322
322
  # (e.g. a Noticed delivery method) should call.
323
- def post_system_message!(body)
324
- 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)
325
330
  end
326
331
 
327
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,16 @@ 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
+ # An authority ending in ":" with nothing after it ("https://host:/x").
44
+ EMPTY_PORT = %r{\A[a-z][a-z0-9+.-]*://[^/?#]*:(?:[/?#]|\z)}i
45
+ ACTION_URL_PORTS = (1..65_535)
46
+
31
47
  belongs_to :conversation,
32
48
  class_name: "Chats::Conversation",
33
49
  inverse_of: :messages,
@@ -56,6 +72,11 @@ module Chats
56
72
  # see the migration template's json_column_default).
57
73
  attribute :metadata, default: -> { {} }
58
74
 
75
+ # The featured button (Chats::Action), or nil. Its own column rather than
76
+ # a key in `metadata`, which stays the host's bag: an action is a
77
+ # contract the bundled bubble renders, so the gem must own where it lives.
78
+ attribute :action, Chats::Action::Type.new
79
+
59
80
  scope :recent_first, -> { order(created_at: :desc, id: :desc) }
60
81
  scope :oldest_first, -> { order(created_at: :asc, id: :asc) }
61
82
  scope :visible, -> { where(deleted_at: nil) }
@@ -97,6 +118,9 @@ module Chats
97
118
  validate :sender_must_not_be_blocked, on: :create
98
119
  validate :conversation_must_not_be_locked, on: :create
99
120
  validate :author_must_be_persisted
121
+ # Checked at the write that SETS it, not on every save: an edit must
122
+ # never fail over an action nobody touched.
123
+ validate :action_must_be_well_formed, if: :will_save_change_to_action?
100
124
  validate :files_must_be_allowed
101
125
 
102
126
  after_create :register_on_conversation
@@ -110,6 +134,14 @@ module Chats
110
134
  def text? = kind == "text"
111
135
  def deleted? = deleted_at.present?
112
136
  def edited? = edited_at.present?
137
+ def action? = !action.nil?
138
+
139
+ # Wraps on the way in, not lazily on first read (ActiveModel casts user
140
+ # input when it is read): `message.action = "/pay"` raises right here,
141
+ # at the line that made the mistake.
142
+ def action=(value)
143
+ super(Chats::Action.wrap(value))
144
+ end
113
145
 
114
146
  def sender_key
115
147
  sender && Chats.messager_key(sender)
@@ -160,6 +192,10 @@ module Chats
160
192
  # Delete according to `config.deletion` (see class comment). Returns
161
193
  # false when deletion is disabled.
162
194
  #
195
+ # A tombstone shows neither the words nor the button that came with
196
+ # them, so the soft path clears the action too — and moderation, which
197
+ # removes content through here, takes the link down with the body.
198
+ #
163
199
  # `enforce_lock: false` is for MODERATION only (see
164
200
  # #remove_reported_field!): a product lock must never shield reported
165
201
  # content from removal.
@@ -170,7 +206,7 @@ module Chats
170
206
  when :soft
171
207
  transaction do
172
208
  files.purge_later if respond_to?(:files) && files.attached?
173
- update!(body: nil, deleted_at: Time.current)
209
+ update!(body: nil, action: nil, deleted_at: Time.current)
174
210
  end
175
211
  true
176
212
  when :hard
@@ -321,6 +357,48 @@ module Chats
321
357
  errors.add(:author, :invalid)
322
358
  end
323
359
 
360
+ # What a button the bubble renders may be: a short label, and a link to
361
+ # an http(s) URL or a path on this site. The host is trusted, but a bad
362
+ # action has to fail HERE, loudly, rather than render a dead link or a
363
+ # `javascript:` one.
364
+ #
365
+ # Two layers, because a browser and URI.parse disagree about where a url
366
+ # goes. First, a string check BEFORE parsing: nothing starting with `//`
367
+ # or `/\` is a path on this site. URI.parse reads `///evil.example/x`
368
+ # as a path with no host, yet a browser resolves it to
369
+ # https://evil.example/x, and treats `/\` like `//`. Then the parse,
370
+ # which rejects whitespace, control characters and backslashes anywhere
371
+ # (`/<tab>/evil.example` is stripped into `//evil.example` by browsers),
372
+ # and decides scheme, host and port.
373
+ def action_must_be_well_formed
374
+ return if action.nil?
375
+
376
+ errors.add(:action, :invalid, count: ACTION_LABEL_LIMIT) unless action_label_fits? && action_url_safe?
377
+ end
378
+
379
+ def action_label_fits?
380
+ action.label.present? && action.label.length <= ACTION_LABEL_LIMIT
381
+ end
382
+
383
+ def action_url_safe?
384
+ url = action.url
385
+ return false if url.blank? || url.length > ACTION_URL_LIMIT
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)
390
+
391
+ uri = URI.parse(url)
392
+ if uri.scheme
393
+ ACTION_URL_SCHEMES.include?(uri.scheme.downcase) && uri.host.present? && ACTION_URL_PORTS.cover?(uri.port)
394
+ else
395
+ # A path on this site: "/rides/new", never "//elsewhere.example".
396
+ url.start_with?("/") && uri.host.nil?
397
+ end
398
+ rescue URI::InvalidURIError
399
+ false
400
+ end
401
+
324
402
  # The subject owns the conversation's openness (Chats::ChatSubject#
325
403
  # chat_locked?). System messages are exempt: the host must always be able
326
404
  # to post "This ticket was closed" into the thread it just closed.
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.3.2"
4
+ VERSION = "0.4.1"
5
5
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # chats 0.4.0 — message ACTIONS.
4
+ #
5
+ # A message may carry one featured button: a label and the url it opens
6
+ # ("Your invoice is ready" → Pay now). Stored as {"label":…,"url":…} in its
7
+ # own JSON column — `metadata` stays yours. Nullable, no default: most
8
+ # messages have no action, and MySQL refuses defaults on JSON columns.
9
+ # See Chats::Action.
10
+ #
11
+ # Safe to run on an install that already has the column (a 0.4.0 fresh
12
+ # install does): every step is guarded, so this migration is a no-op there.
13
+ class AddActionToChatsMessages < ActiveRecord::Migration<%= migration_version %>
14
+ def up
15
+ return if column_exists?(:chats_messages, :action)
16
+
17
+ add_column :chats_messages, :action, json_column_type
18
+ end
19
+
20
+ # Rolling back KEEPS the column, on purpose: this migration never removes
21
+ # a column it may not have added. On a fresh 0.4.0 install the create
22
+ # migration owns `action` and `up` above did nothing, so dropping it here
23
+ # would destroy every message's button with it — and nothing a migration
24
+ # can see records which of the two added it. A leftover nullable column
25
+ # is harmless to older versions of chats; drop it by hand if you mean to.
26
+ def down; end
27
+
28
+ private
29
+
30
+ # jsonb on every PostgreSQL adapter, like the install migration — matched
31
+ # by prefix because PostGIS answers "PostGIS", not "PostgreSQL".
32
+ def json_column_type
33
+ return :jsonb if connection.adapter_name.match?(/\Apostg/i)
34
+
35
+ :json
36
+ end
37
+ end
@@ -101,6 +101,10 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
101
101
  t.datetime :edited_at
102
102
  t.datetime :deleted_at
103
103
  t.send(json_column_type, :metadata, default: json_column_default)
104
+ # The one featured button a message may carry ({"label":…,"url":…}),
105
+ # set by the host from Ruby — see Chats::Action. Nullable with no
106
+ # default: most messages have none, and MySQL refuses JSON defaults.
107
+ t.send(json_column_type, :action)
104
108
 
105
109
  t.timestamps
106
110
  end
@@ -223,6 +223,7 @@ Chats.configure do |config|
223
223
  # app/views/chats/slots/_inbox_top.html.erb above the first inbox row
224
224
  # app/views/chats/slots/_inbox_empty.html.erb inside the empty state
225
225
  # app/views/chats/slots/_conversation_header_actions.html.erb thread menu
226
+ # app/views/chats/slots/_composer_actions.html.erb your own buttons above the composer
226
227
  # app/views/chats/slots/_locked_composer.html.erb the locked composer's body
227
228
  # app/views/chats/slots/_message_meta.html.erb after each bubble's timestamp
228
229
  #