chats 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -0
- data/README.md +232 -10
- data/app/assets/stylesheets/chats.css +95 -1
- data/app/controllers/chats/messages_controller.rb +1 -13
- 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/chats-0.3.1.gem +0 -0
- 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/engine.rb +1 -1
- data/lib/chats/inbox.rb +30 -4
- data/lib/chats/models/concerns/messager.rb +10 -7
- data/lib/chats/models/conversation.rb +20 -22
- data/lib/chats/models/message.rb +81 -8
- data/lib/chats/models/participant.rb +9 -1
- data/lib/chats/send_rate_limited.rb +30 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +1 -0
- 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 +11 -2
|
@@ -14,6 +14,22 @@
|
|
|
14
14
|
and on return-to-visible. It needs to wrap the <turbo-cable-stream-source>
|
|
15
15
|
that `turbo_stream_from` renders so it can observe its `connected`
|
|
16
16
|
attribute as the heartbeat. %>
|
|
17
|
+
|
|
18
|
+
<%# The OTHER inbox-staleness case, distinct from a missed broadcast: a Turbo
|
|
19
|
+
RESTORATION visit (browser back / Hotwire Native stack pop) serves the
|
|
20
|
+
inbox's cached snapshot with no GET. Open a chat from a profile, send a
|
|
21
|
+
message, tap back — the snapshot cached before the chat existed comes back
|
|
22
|
+
and the new conversation is missing until you navigate away and return.
|
|
23
|
+
The reconciler above can't help (it only fires on reconnect / visibility,
|
|
24
|
+
not on a restore visit). `turbo-cache-control: no-cache` makes the inbox
|
|
25
|
+
uncacheable, so a restore visit re-fetches from the network and is always
|
|
26
|
+
fresh — on the web AND in Hotwire Native, whose back-pop issues a Turbo
|
|
27
|
+
`.restore` visit that honors this. Requires the host layout to
|
|
28
|
+
`yield :head` (the Rails convention); harmless no-op otherwise.
|
|
29
|
+
https://turbo.hotwired.dev/handbook/building#opting-out-of-caching %>
|
|
30
|
+
<% content_for :head do %>
|
|
31
|
+
<meta name="turbo-cache-control" content="no-cache">
|
|
32
|
+
<% end %>
|
|
17
33
|
<%= chats_styles %>
|
|
18
34
|
<div data-controller="chats--refresh-inbox">
|
|
19
35
|
<%= turbo_stream_from chats_current_messager, :chats_inbox %>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# A message's featured button (Chats::Action): the one place it takes you,
|
|
2
|
+
under the words it came with. Rendered inside the bubble, so it is part
|
|
3
|
+
of the single viewer-agnostic broadcast render AND of the clone the
|
|
4
|
+
long-press popup lifts — which is why it is a plain link and never a
|
|
5
|
+
form: a form would need a per-viewer CSRF token baked into a render
|
|
6
|
+
every subscriber shares. An action is a destination, not a mutation.
|
|
7
|
+
|
|
8
|
+
data-turbo-frame="_top": older pages of a thread arrive inside lazy
|
|
9
|
+
`chats_page_…` turbo-frames, and a link inside a frame navigates the
|
|
10
|
+
FRAME. _top makes it navigate the page, wherever the bubble landed.
|
|
11
|
+
|
|
12
|
+
The url was validated at the write (Chats::Message#
|
|
13
|
+
action_must_be_well_formed): http(s) or a path on this site, never
|
|
14
|
+
`javascript:` or protocol-relative. %>
|
|
15
|
+
<%= link_to action.label, action.url, class: "chats-message__action", data: { turbo_frame: "_top" } %>
|
|
@@ -1,71 +1,85 @@
|
|
|
1
1
|
<%# The composer. Submits as a Turbo Stream (the sender's bubble appends from
|
|
2
2
|
the response — instant, no cable round-trip needed); the chats--composer
|
|
3
3
|
controller adds autosize, desktop Enter-to-send, throttled typing pings,
|
|
4
|
-
and reset-on-success. Plain form POST still works with JS disabled.
|
|
5
|
-
<%# id: the swap target — a conversation that locks while this form is open
|
|
6
|
-
gets the locked notice rendered straight over it (see
|
|
7
|
-
chats/messages/locked.turbo_stream.erb). %>
|
|
8
|
-
<%= form_with model: Chats::Message.new,
|
|
9
|
-
url: conversation_messages_path(conversation),
|
|
10
|
-
id: dom_id(conversation, :composer),
|
|
11
|
-
class: "chats-composer",
|
|
12
|
-
data: {
|
|
13
|
-
controller: "chats--composer",
|
|
14
|
-
"chats--composer-typing-url-value": (typing_conversation_path(conversation) if Chats.config.typing_indicators),
|
|
15
|
-
action: "turbo:submit-end->chats--composer#submitted chats:edit-message@window->chats--composer#beginEdit"
|
|
16
|
-
} do |form| %>
|
|
4
|
+
and reset-on-success. Plain form POST still works with JS disabled.
|
|
17
5
|
|
|
18
|
-
|
|
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/chats-0.3.1.gem
ADDED
|
Binary file
|
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
|
data/lib/chats/engine.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Chats
|
|
|
30
30
|
CHATS_LIB = File.expand_path("chats", LIB_ROOT)
|
|
31
31
|
|
|
32
32
|
ZEITWERK_IGNORED = %w[
|
|
33
|
-
version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb
|
|
33
|
+
version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb send_rate_limited.rb
|
|
34
34
|
].freeze
|
|
35
35
|
|
|
36
36
|
initializer "chats.autoload", before: :set_autoload_paths do
|
data/lib/chats/inbox.rb
CHANGED
|
@@ -151,10 +151,13 @@ module Chats
|
|
|
151
151
|
else
|
|
152
152
|
stacked = Chats::Conversation.direct.where(id: stacked_seats)
|
|
153
153
|
@ungrouped_relation = base_relation.where.not(id: stacked).limit(limit)
|
|
154
|
-
|
|
155
|
-
# the
|
|
156
|
-
#
|
|
157
|
-
|
|
154
|
+
recent = base_relation.direct.where(id: stacked_seats).except(:includes).select(:id).limit(limit)
|
|
155
|
+
# Preserve the recent search window, plus the freshest conversation
|
|
156
|
+
# of each counterpart. A busy stack cannot consume another stack's
|
|
157
|
+
# row; at most twice the row limit is materialized.
|
|
158
|
+
candidates = base_relation.where(id: limited_ids(recent))
|
|
159
|
+
.or(base_relation.where(id: limited_ids(stack_representatives)))
|
|
160
|
+
@stacked = apply_search(candidates)
|
|
158
161
|
end
|
|
159
162
|
|
|
160
163
|
@ungrouped = apply_search(@ungrouped_relation)
|
|
@@ -163,6 +166,29 @@ module Chats
|
|
|
163
166
|
@flat = @stacked.empty? && query.nil? ? @ungrouped_relation : @ungrouped + @stacked
|
|
164
167
|
end
|
|
165
168
|
|
|
169
|
+
# MySQL rejects LIMIT directly in an IN subquery. A derived table keeps
|
|
170
|
+
# the bounded selection in SQL and works on all three supported adapters.
|
|
171
|
+
def limited_ids(relation)
|
|
172
|
+
Chats::Conversation.from(relation, :limited_conversations).select(:id)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# ROW_NUMBER works on every supported adapter (PostgreSQL, SQLite and
|
|
176
|
+
# MySQL 8). Ranking in SQL avoids loading an entire busy desk's history.
|
|
177
|
+
def stack_representatives
|
|
178
|
+
activity = "COALESCE(chats_conversations.last_message_at, chats_conversations.created_at)"
|
|
179
|
+
ranking = "ROW_NUMBER() OVER (PARTITION BY stack_seats.messager_type, stack_seats.messager_id " \
|
|
180
|
+
"ORDER BY #{activity} DESC, chats_conversations.id DESC) AS stack_rank"
|
|
181
|
+
ranked = base_relation.direct.except(:includes, :order)
|
|
182
|
+
.joins("INNER JOIN chats_participants stack_seats " \
|
|
183
|
+
"ON stack_seats.conversation_id = chats_conversations.id")
|
|
184
|
+
.where(stack_seats: { messager_type: grouped_types })
|
|
185
|
+
.where.not(stack_seats: { messager_type: viewer.class.polymorphic_name,
|
|
186
|
+
messager_id: viewer.id })
|
|
187
|
+
.select("chats_conversations.id, #{activity} AS activity_at", ranking)
|
|
188
|
+
Chats::Conversation.from(ranked, :ranked_stacks).where("stack_rank = 1")
|
|
189
|
+
.order(Arel.sql("activity_at DESC, id DESC")).limit(limit).select(:id)
|
|
190
|
+
end
|
|
191
|
+
|
|
166
192
|
# Only the direct threads shared with one counterpart. Direct only, by
|
|
167
193
|
# design: a group that happens to include the desk is not part of the
|
|
168
194
|
# desk's stack.
|
|
@@ -10,6 +10,8 @@ module Chats
|
|
|
10
10
|
#
|
|
11
11
|
# alice.message!(bob, "hola!") # DM in one line
|
|
12
12
|
# alice.message!(conversation, "hola!") # or into a conversation
|
|
13
|
+
# alice.message!(bob, "Let's play!", # with a button under it
|
|
14
|
+
# action: { label: "Join my room", url: "/play/abcd" })
|
|
13
15
|
#
|
|
14
16
|
# alice.chats # her inbox, newest first
|
|
15
17
|
# alice.unread_chats_count # for the nav badge
|
|
@@ -155,19 +157,20 @@ module Chats
|
|
|
155
157
|
# sender stays the conversation identity. See Chats::Message#signed?.
|
|
156
158
|
#
|
|
157
159
|
# desk.message!(alice, "On it!", author: lucia)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
#
|
|
161
|
+
# `action:` is the one featured button under the words — a label and a
|
|
162
|
+
# url (see Chats::Action). It rides on a message the body rules already
|
|
163
|
+
# accept; an action alone is not a message.
|
|
164
|
+
#
|
|
165
|
+
# alice.message!(bob, "Let's play!", action: { label: "Join my room", url: room_path(room) })
|
|
166
|
+
def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil, action: nil)
|
|
164
167
|
conversation =
|
|
165
168
|
case target
|
|
166
169
|
when Chats::Conversation then target
|
|
167
170
|
else chat_with(target, about: about)
|
|
168
171
|
end
|
|
169
172
|
|
|
170
|
-
attributes = { sender: self, body: body, reply_to: reply_to, author: author }
|
|
173
|
+
attributes = { sender: self, body: body, reply_to: reply_to, author: author, action: action }
|
|
171
174
|
attributes[:files] = files if files.present?
|
|
172
175
|
conversation.messages.create!(**attributes)
|
|
173
176
|
end
|
|
@@ -114,6 +114,8 @@ module Chats
|
|
|
114
114
|
# mechanism. https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-create_or_find_by
|
|
115
115
|
validate :groups_must_be_enabled, if: :group?
|
|
116
116
|
|
|
117
|
+
after_create_commit -> { Chats.notify(:conversation_created, conversation: self) }
|
|
118
|
+
|
|
117
119
|
# --- Finding & creating ---------------------------------------------------
|
|
118
120
|
|
|
119
121
|
class << self
|
|
@@ -133,21 +135,17 @@ module Chats
|
|
|
133
135
|
raise Chats::BlockedError, "messagers are blocked" if Chats.blocked_between?(a, b)
|
|
134
136
|
raise Chats::NotAllowedError, "policy forbids messaging" unless Chats.can_message?(a, b)
|
|
135
137
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
transaction do
|
|
139
|
+
conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c|
|
|
140
|
+
c.kind = "direct"
|
|
141
|
+
c.subject = about
|
|
142
|
+
end
|
|
143
|
+
# Keep the roster in the creation transaction: the after-commit
|
|
144
|
+
# event must describe a complete conversation, including under an
|
|
145
|
+
# outer host transaction or a failed participant insertion.
|
|
146
|
+
[a, b].each { |messager| conversation.add_participant!(messager) }
|
|
147
|
+
conversation
|
|
139
148
|
end
|
|
140
|
-
|
|
141
|
-
# `create_or_find_by!` may have FOUND a conversation created a moment
|
|
142
|
-
# ago by the other side — participants are ensured idempotently
|
|
143
|
-
# either way (their own unique index makes this race-safe too).
|
|
144
|
-
# `previously_new_record?` is how we tell the two apart, so the
|
|
145
|
-
# :conversation_created event fires ONCE per conversation, not on
|
|
146
|
-
# every resume.
|
|
147
|
-
created = conversation.previously_new_record?
|
|
148
|
-
[a, b].each { |messager| conversation.add_participant!(messager) }
|
|
149
|
-
Chats.notify(:conversation_created, conversation: conversation) if created
|
|
150
|
-
conversation
|
|
151
149
|
end
|
|
152
150
|
|
|
153
151
|
# Create a group conversation. +others+ excludes the creator (who joins
|
|
@@ -163,17 +161,12 @@ module Chats
|
|
|
163
161
|
others = Array(others) - [creator]
|
|
164
162
|
raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
|
|
165
163
|
|
|
166
|
-
|
|
164
|
+
transaction do
|
|
167
165
|
created = create!(kind: "group", title: title, subject: about)
|
|
168
166
|
created.add_participant!(creator, role: "owner")
|
|
169
167
|
others.each { |messager| created.add_participant!(messager) }
|
|
170
168
|
created
|
|
171
169
|
end
|
|
172
|
-
|
|
173
|
-
# Emitted AFTER the transaction: subscribers see a complete roster
|
|
174
|
-
# and never run inside the write that created it.
|
|
175
|
-
Chats.notify(:conversation_created, conversation: conversation)
|
|
176
|
-
conversation
|
|
177
170
|
end
|
|
178
171
|
|
|
179
172
|
# Deterministic identity for a direct pair (+ optional subject).
|
|
@@ -327,8 +320,13 @@ module Chats
|
|
|
327
320
|
# cancelled", "Payment received" — rendered as a centered system note,
|
|
328
321
|
# not a bubble. This is the stable entry point notification systems
|
|
329
322
|
# (e.g. a Noticed delivery method) should call.
|
|
330
|
-
|
|
331
|
-
|
|
323
|
+
#
|
|
324
|
+
# `action:` puts a button under the note (see Chats::Action):
|
|
325
|
+
#
|
|
326
|
+
# conversation.post_system_message!("Your ride was cancelled",
|
|
327
|
+
# action: { label: "Book another", url: "/rides/new" })
|
|
328
|
+
def post_system_message!(body, action: nil)
|
|
329
|
+
messages.create!(kind: "system", body: body, action: action)
|
|
332
330
|
end
|
|
333
331
|
|
|
334
332
|
# --- Read state -----------------------------------------------------------
|
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) }
|
|
@@ -96,7 +115,10 @@ module Chats
|
|
|
96
115
|
validate :sender_must_be_active_participant, on: :create
|
|
97
116
|
validate :sender_must_not_be_blocked, on: :create
|
|
98
117
|
validate :conversation_must_not_be_locked, on: :create
|
|
99
|
-
validate :
|
|
118
|
+
validate :author_must_be_persisted
|
|
119
|
+
# Checked at the write that SETS it, not on every save: an edit must
|
|
120
|
+
# never fail over an action nobody touched.
|
|
121
|
+
validate :action_must_be_well_formed, if: :will_save_change_to_action?
|
|
100
122
|
validate :files_must_be_allowed
|
|
101
123
|
|
|
102
124
|
after_create :register_on_conversation
|
|
@@ -110,6 +132,14 @@ module Chats
|
|
|
110
132
|
def text? = kind == "text"
|
|
111
133
|
def deleted? = deleted_at.present?
|
|
112
134
|
def edited? = edited_at.present?
|
|
135
|
+
def action? = !action.nil?
|
|
136
|
+
|
|
137
|
+
# Wraps on the way in, not lazily on first read (ActiveModel casts user
|
|
138
|
+
# input when it is read): `message.action = "/pay"` raises right here,
|
|
139
|
+
# at the line that made the mistake.
|
|
140
|
+
def action=(value)
|
|
141
|
+
super(Chats::Action.wrap(value))
|
|
142
|
+
end
|
|
113
143
|
|
|
114
144
|
def sender_key
|
|
115
145
|
sender && Chats.messager_key(sender)
|
|
@@ -160,6 +190,10 @@ module Chats
|
|
|
160
190
|
# Delete according to `config.deletion` (see class comment). Returns
|
|
161
191
|
# false when deletion is disabled.
|
|
162
192
|
#
|
|
193
|
+
# A tombstone shows neither the words nor the button that came with
|
|
194
|
+
# them, so the soft path clears the action too — and moderation, which
|
|
195
|
+
# removes content through here, takes the link down with the body.
|
|
196
|
+
#
|
|
163
197
|
# `enforce_lock: false` is for MODERATION only (see
|
|
164
198
|
# #remove_reported_field!): a product lock must never shield reported
|
|
165
199
|
# content from removal.
|
|
@@ -170,7 +204,7 @@ module Chats
|
|
|
170
204
|
when :soft
|
|
171
205
|
transaction do
|
|
172
206
|
files.purge_later if respond_to?(:files) && files.attached?
|
|
173
|
-
update!(body: nil, deleted_at: Time.current)
|
|
207
|
+
update!(body: nil, action: nil, deleted_at: Time.current)
|
|
174
208
|
end
|
|
175
209
|
true
|
|
176
210
|
when :hard
|
|
@@ -312,13 +346,52 @@ module Chats
|
|
|
312
346
|
errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
|
|
313
347
|
end
|
|
314
348
|
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
def
|
|
319
|
-
return if author.nil? ||
|
|
349
|
+
# Authorship is a signature, not a conversation seat. A host may keep
|
|
350
|
+
# staff in a separate model without giving them messaging capabilities.
|
|
351
|
+
# Never implicitly create that identity while sending a message.
|
|
352
|
+
def author_must_be_persisted
|
|
353
|
+
return if author.nil? || author.persisted?
|
|
354
|
+
|
|
355
|
+
errors.add(:author, :invalid)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# What a button the bubble renders may be: a short label, and a link to
|
|
359
|
+
# an http(s) URL or a path on this site. The host is trusted, but a bad
|
|
360
|
+
# action has to fail HERE, loudly, rather than render a dead link or a
|
|
361
|
+
# `javascript:` one.
|
|
362
|
+
#
|
|
363
|
+
# Two layers, because a browser and URI.parse disagree about where a url
|
|
364
|
+
# goes. First, a string check BEFORE parsing: nothing starting with `//`
|
|
365
|
+
# or `/\` is a path on this site. URI.parse reads `///evil.example/x`
|
|
366
|
+
# as a path with no host, yet a browser resolves it to
|
|
367
|
+
# https://evil.example/x, and treats `/\` like `//`. Then the parse,
|
|
368
|
+
# which rejects whitespace, control characters and backslashes anywhere
|
|
369
|
+
# (`/<tab>/evil.example` is stripped into `//evil.example` by browsers),
|
|
370
|
+
# and decides scheme, host and port.
|
|
371
|
+
def action_must_be_well_formed
|
|
372
|
+
return if action.nil?
|
|
320
373
|
|
|
321
|
-
errors.add(:
|
|
374
|
+
errors.add(:action, :invalid, count: ACTION_LABEL_LIMIT) unless action_label_fits? && action_url_safe?
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def action_label_fits?
|
|
378
|
+
action.label.present? && action.label.length <= ACTION_LABEL_LIMIT
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def action_url_safe?
|
|
382
|
+
url = action.url
|
|
383
|
+
return false if url.blank? || url.length > ACTION_URL_LIMIT
|
|
384
|
+
return false if url.start_with?("//", "/\\")
|
|
385
|
+
|
|
386
|
+
uri = URI.parse(url)
|
|
387
|
+
if uri.scheme
|
|
388
|
+
ACTION_URL_SCHEMES.include?(uri.scheme.downcase) && uri.host.present? && ACTION_URL_PORTS.cover?(uri.port)
|
|
389
|
+
else
|
|
390
|
+
# A path on this site: "/rides/new", never "//elsewhere.example".
|
|
391
|
+
url.start_with?("/") && uri.host.nil?
|
|
392
|
+
end
|
|
393
|
+
rescue URI::InvalidURIError
|
|
394
|
+
false
|
|
322
395
|
end
|
|
323
396
|
|
|
324
397
|
# The subject owns the conversation's openness (Chats::ChatSubject#
|