chats 0.3.2 → 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.
@@ -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,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
@@ -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,14 +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)
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
@@ -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,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) }
@@ -97,6 +116,9 @@ module Chats
97
116
  validate :sender_must_not_be_blocked, on: :create
98
117
  validate :conversation_must_not_be_locked, on: :create
99
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
@@ -321,6 +355,45 @@ module Chats
321
355
  errors.add(:author, :invalid)
322
356
  end
323
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?
373
+
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
395
+ end
396
+
324
397
  # The subject owns the conversation's openness (Chats::ChatSubject#
325
398
  # chat_locked?). System messages are exempt: the host must always be able
326
399
  # 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.0"
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
  #
@@ -9,14 +9,17 @@ module Chats
9
9
  # needs into an EXISTING install. Nothing else: the initializer, the
10
10
  # views and the routes you already own stay untouched.
11
11
  #
12
- # Currently writes the 0.2.0 migration (message authorship). It is
13
- # written guarded, so running it against an install that already has the
14
- # columns (a fresh 0.2.0 install) is a no-op rather than an error.
12
+ # Writes every migration since 0.1.x — 0.2.0's message authorship and
13
+ # 0.4.0's message action — in that order. Each is written guarded, so
14
+ # running this against an install that already has some of the columns
15
+ # (a fresh 0.2.0 install has the author ones) is a no-op for those
16
+ # rather than an error, and re-running it skips files already copied.
17
+ # The action migration is not written at all where the column exists.
15
18
  class UpgradeGenerator < Rails::Generators::Base
16
19
  include ActiveRecord::Generators::Migration
17
20
 
18
21
  source_root File.expand_path("templates", __dir__)
19
- desc "Add the migrations a chats version bump needs (0.2.0: message authorship)"
22
+ desc "Add the migrations a chats version bump needs (0.2.0: message authorship; 0.4.0: message actions)"
20
23
 
21
24
  def self.next_migration_number(dir)
22
25
  ActiveRecord::Generators::Base.next_migration_number(dir)
@@ -27,19 +30,47 @@ module Chats
27
30
  File.join(db_migrate_path, "add_author_to_chats_messages.rb")
28
31
  end
29
32
 
33
+ # Not written when the schema already HAS the column — a fresh 0.4.0
34
+ # install, whose create migration owns it. A second migration for a
35
+ # column it didn't add is one nobody can safely roll back. Without a
36
+ # database to ask, it is written anyway; its `up` is guarded, and its
37
+ # `down` never drops the column.
38
+ def create_action_migration
39
+ if action_column_present?
40
+ say_status :skip, "chats_messages.action already exists — no migration needed", :yellow
41
+ return
42
+ end
43
+
44
+ migration_template "add_action_to_chats_messages.rb.erb",
45
+ File.join(db_migrate_path, "add_action_to_chats_messages.rb")
46
+ end
47
+
30
48
  def display_post_upgrade_message
31
49
  say "\n💬 chats upgrade migrations copied.", :green
32
50
  say "\n 1. Run 'rails db:migrate'."
33
- say " 2. New in 0.2.0 — all opt-in, nothing changes until you ask:"
51
+ say " 2. New in 0.4.0 — a message can carry one button (the `action` column):"
52
+ say " alice.message!(bob, \"Let's play!\", action: { label: \"Join my room\", url: room_path(room) })"
53
+ say " app/views/chats/slots/_composer_actions.html.erb # your own buttons by the composer"
54
+ say " 3. New in 0.2.0 — all opt-in, nothing changes until you ask:"
34
55
  say " acts_as_messager notifications: false, blockable: false, inbox: :grouped"
35
56
  say " Chats.on(:message_created) { |message| … } # replaces config.notifier"
36
57
  say " config.messager_url / config.message_signature / config.inbox_scope"
37
58
  say " chat_locked? / chat_locked_notice on your chat subjects"
38
- say " 3. See the CHANGELOG for the full list.\n", :green
59
+ say " 4. See the CHANGELOG for the full list.\n", :green
39
60
  end
40
61
 
41
62
  private
42
63
 
64
+ def action_column_present?
65
+ ActiveRecord::Base.connection_pool.with_connection do |connection|
66
+ connection.column_exists?(:chats_messages, :action)
67
+ end
68
+ rescue ActiveRecord::ActiveRecordError
69
+ # No database configured, not created yet, or no chats tables: there
70
+ # is nothing to ask, so write the (guarded) migration.
71
+ false
72
+ end
73
+
43
74
  def migration_version
44
75
  "[#{ActiveRecord::VERSION::STRING.to_f}]"
45
76
  end