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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +114 -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 +85 -0
- data/lib/chats/broadcasts.rb +4 -4
- data/lib/chats/inbox.rb +6 -10
- data/lib/chats/models/concerns/messager.rb +20 -4
- data/lib/chats/models/conversation.rb +7 -2
- data/lib/chats/models/message.rb +79 -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
|
@@ -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
|
-
|
|
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,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
|
data/lib/chats/broadcasts.rb
CHANGED
|
@@ -120,10 +120,10 @@ module Chats
|
|
|
120
120
|
target: "chats_unread_badge",
|
|
121
121
|
partial: BADGE_PARTIAL,
|
|
122
122
|
# Counted at broadcast-enqueue time: the badge is advisory UI; a
|
|
123
|
-
# count that's seconds stale self-heals on the next event.
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
locals: { count:
|
|
123
|
+
# count that's seconds stale self-heals on the next event. The
|
|
124
|
+
# SAME count the page renders (`chats_unread_badge`), so what the
|
|
125
|
+
# inbox scope hides never lights the badge.
|
|
126
|
+
locals: { count: messager.unread_chats_count }
|
|
127
127
|
)
|
|
128
128
|
end
|
|
129
129
|
|
data/lib/chats/inbox.rb
CHANGED
|
@@ -115,15 +115,12 @@ module Chats
|
|
|
115
115
|
Chats.config.inbox_limit
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
118
|
+
# The viewer's own inbox relation (`Messager#chats`: `inbox_for` with
|
|
119
|
+
# `config.inbox_scope` applied, and whatever the host's model adds on
|
|
120
|
+
# top), memoized so it is built ONCE per inbox however many legs the
|
|
121
|
+
# row query is split into (the stack aggregates build their own).
|
|
121
122
|
def base_relation
|
|
122
|
-
@base_relation ||=
|
|
123
|
-
relation = Chats::Conversation.inbox_for(viewer)
|
|
124
|
-
.includes(:last_message, :subject, participants: :messager)
|
|
125
|
-
Chats.config.inbox_scope.call(relation, viewer) || relation
|
|
126
|
-
end
|
|
123
|
+
@base_relation ||= viewer.chats.includes(:last_message, :subject, participants: :messager)
|
|
127
124
|
end
|
|
128
125
|
|
|
129
126
|
# The polymorphic types worth splitting out. Empty when nothing stacks
|
|
@@ -244,12 +241,11 @@ module Chats
|
|
|
244
241
|
# aggregates — independent of how deep the stack is, and of how much of
|
|
245
242
|
# it we loaded.
|
|
246
243
|
def stack_totals(messager)
|
|
247
|
-
scope =
|
|
244
|
+
scope = viewer.chats.reorder(nil).direct.where(
|
|
248
245
|
id: Chats::Participant.select(:conversation_id).where(
|
|
249
246
|
messager_type: messager.class.polymorphic_name, messager_id: messager.id
|
|
250
247
|
)
|
|
251
248
|
)
|
|
252
|
-
scope = Chats.config.inbox_scope.call(scope, viewer) || scope
|
|
253
249
|
|
|
254
250
|
{
|
|
255
251
|
open: scope.distinct.count,
|
|
@@ -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
|
-
|
|
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 —
|
|
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
|
-
|
|
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,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
|
@@ -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
|
#
|