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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/README.md +227 -9
- data/app/assets/stylesheets/chats.css +95 -1
- data/app/helpers/chats/engine_helper.rb +2 -1
- data/app/views/chats/conversations/_locked_composer.html.erb +2 -2
- data/app/views/chats/conversations/index.html.erb +16 -0
- data/app/views/chats/messages/_action.html.erb +15 -0
- data/app/views/chats/messages/_composer.html.erb +74 -60
- data/app/views/chats/messages/_message.html.erb +6 -1
- data/config/locales/en.yml +5 -0
- data/config/locales/es.yml +5 -0
- data/docs/screenshots/composer-actions.png +0 -0
- data/docs/screenshots/inbox.png +0 -0
- data/docs/screenshots/system-note-action.png +0 -0
- data/docs/screenshots/thread-actions-phone.png +0 -0
- data/lib/chats/action.rb +70 -0
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +7 -2
- data/lib/chats/models/message.rb +74 -1
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +4 -0
- data/lib/generators/chats/templates/initializer.rb +1 -0
- data/lib/generators/chats/upgrade_generator.rb +37 -6
- metadata +9 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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"
|
|
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/config/locales/en.yml
CHANGED
|
@@ -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:
|
data/config/locales/es.yml
CHANGED
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/chats/action.rb
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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 -----------------------------------------------------------
|
data/lib/chats/models/message.rb
CHANGED
|
@@ -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
|
@@ -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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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.
|
|
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 "
|
|
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
|