chats 0.1.1 → 0.6.2
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 +1037 -0
- data/README.md +621 -40
- data/app/assets/stylesheets/chats.css +906 -2
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +396 -86
- data/app/controllers/chats/messages_controller.rb +35 -13
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +230 -7
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1120 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +76 -0
- data/app/javascript/chats/split_controller.js +131 -0
- data/app/javascript/chats/thread_controller.js +322 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +61 -0
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
- data/app/views/chats/conversations/_thread.html.erb +147 -0
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +142 -15
- data/app/views/chats/conversations/pane.html.erb +14 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +18 -134
- data/app/views/chats/conversations/window.html.erb +26 -0
- data/app/views/chats/dock/_badge.html.erb +12 -0
- data/app/views/chats/dock/_dock.html.erb +139 -0
- data/app/views/chats/dock/_signal.html.erb +26 -0
- data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
- data/app/views/chats/dock/_window.html.erb +23 -0
- data/app/views/chats/dock/_window_header.html.erb +75 -0
- data/app/views/chats/dock/_window_shell.html.erb +17 -0
- data/app/views/chats/messages/_action.html.erb +15 -0
- data/app/views/chats/messages/_composer.html.erb +84 -56
- data/app/views/chats/messages/_message.html.erb +43 -15
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +10 -1
- data/config/locales/en.yml +40 -0
- data/config/locales/es.yml +40 -0
- data/config/routes.rb +8 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/docs/screenshots/composer-actions.png +0 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/docs/screenshots/inbox.png +0 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/docs/screenshots/system-note-action.png +0 -0
- data/docs/screenshots/thread-actions-phone.png +0 -0
- data/gemfiles/rails_7.1.gemfile +3 -0
- data/gemfiles/rails_7.2.gemfile +3 -0
- data/gemfiles/rails_8.1.gemfile +3 -0
- data/lib/chats/action.rb +85 -0
- data/lib/chats/broadcasts.rb +178 -17
- data/lib/chats/configuration.rb +115 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +363 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/macros.rb +33 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +122 -6
- data/lib/chats/models/conversation.rb +153 -19
- data/lib/chats/models/message.rb +292 -21
- data/lib/chats/models/participant.rb +67 -0
- data/lib/chats/models/reaction.rb +18 -0
- data/lib/chats/send_rate_limited.rb +30 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +123 -15
- data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
- data/lib/generators/chats/templates/initializer.rb +125 -14
- data/lib/generators/chats/upgrade_generator.rb +79 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +51 -2
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<%# A dock window's header. Rendered with the thread (window.html.erb) and,
|
|
2
|
+
with only a name, as the shell a window shows while it loads or after it
|
|
3
|
+
failed (chats/dock/_window_shell) — so minimize and close always work.
|
|
4
|
+
|
|
5
|
+
Locals: conversation (nil in the shell), title, counterpart (or nil). %>
|
|
6
|
+
<% conversation = local_assigns[:conversation] %>
|
|
7
|
+
<% counterpart = local_assigns[:counterpart] %>
|
|
8
|
+
<header class="chats-window__header">
|
|
9
|
+
<%# Phones show one full-screen window: back returns to the list. %>
|
|
10
|
+
<button type="button" class="chats-window__icon chats-window__back"
|
|
11
|
+
aria-label="<%= t("chats.dock.back") %>" title="<%= t("chats.dock.back") %>"
|
|
12
|
+
data-action="chats--dock#back">
|
|
13
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
14
|
+
stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M19 12H5m6-6-6 6 6 6"/></svg>
|
|
15
|
+
</button>
|
|
16
|
+
|
|
17
|
+
<button type="button" class="chats-window__title" aria-expanded="true" title="<%= title %>"
|
|
18
|
+
data-chats-dock-minimize data-action="chats--dock#minimize">
|
|
19
|
+
<% if counterpart %>
|
|
20
|
+
<%= chats_messager_avatar(counterpart) %>
|
|
21
|
+
<% elsif conversation %>
|
|
22
|
+
<%= chats_conversation_avatar(conversation, chats_current_messager) %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<span class="chats-window__identity">
|
|
25
|
+
<span class="chats-window__name-row">
|
|
26
|
+
<span class="chats-window__name" data-chats-dock-name><%= title %></span>
|
|
27
|
+
<%= chats_verified_badge(counterpart) %>
|
|
28
|
+
</span>
|
|
29
|
+
<% if conversation&.subject_label %>
|
|
30
|
+
<span class="chats-window__subject"><%= conversation.subject_label %></span>
|
|
31
|
+
<% end %>
|
|
32
|
+
</span>
|
|
33
|
+
<%# The conversation's unread count while the window waits to be seen
|
|
34
|
+
(filled in by the dock's controller). %>
|
|
35
|
+
<span class="chats-badge chats-window__unread" data-chats-dock-window-unread hidden></span>
|
|
36
|
+
</button>
|
|
37
|
+
|
|
38
|
+
<%# The host's own thread actions (block, report, view profile), as on
|
|
39
|
+
the page's ⋯ menu, with the same locals. %>
|
|
40
|
+
<% if conversation && chats_slot?(:conversation_header_actions) %>
|
|
41
|
+
<details class="chats-menu chats-window__menu">
|
|
42
|
+
<summary class="chats-window__icon" aria-label="<%= t("chats.thread.options") %>"
|
|
43
|
+
title="<%= t("chats.thread.options") %>">⋯</summary>
|
|
44
|
+
<div class="chats-menu__panel">
|
|
45
|
+
<%= chats_slot :conversation_header_actions,
|
|
46
|
+
conversation: conversation,
|
|
47
|
+
viewer: chats_current_messager,
|
|
48
|
+
counterpart: counterpart,
|
|
49
|
+
blockable: chats_blockable?(counterpart) %>
|
|
50
|
+
</div>
|
|
51
|
+
</details>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
54
|
+
<% if conversation %>
|
|
55
|
+
<%= link_to chats_routes.conversation_path(conversation),
|
|
56
|
+
class: "chats-window__icon chats-window__expand",
|
|
57
|
+
title: t("chats.dock.open_full"), "aria-label": t("chats.dock.open_full"),
|
|
58
|
+
data: { turbo_frame: "_top" } do %>
|
|
59
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
60
|
+
stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>
|
|
61
|
+
<% end %>
|
|
62
|
+
<% end %>
|
|
63
|
+
<button type="button" class="chats-window__icon chats-window__minimize"
|
|
64
|
+
aria-label="<%= t("chats.dock.minimize") %>" title="<%= t("chats.dock.minimize") %>"
|
|
65
|
+
data-action="chats--dock#minimize">
|
|
66
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
67
|
+
aria-hidden="true" focusable="false"><path d="M5 12h14"/></svg>
|
|
68
|
+
</button>
|
|
69
|
+
<button type="button" class="chats-window__icon chats-window__close"
|
|
70
|
+
aria-label="<%= t("chats.dock.close") %>" title="<%= t("chats.dock.close") %>"
|
|
71
|
+
data-action="chats--dock#close">
|
|
72
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
73
|
+
aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18"/></svg>
|
|
74
|
+
</button>
|
|
75
|
+
</header>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%# What a window shows before its thread arrives, and if it never does: the
|
|
2
|
+
header (so minimize and close work) and a status line. The dock renders
|
|
3
|
+
it once, as <template>s its controller clones; `chats/dock/_window`
|
|
4
|
+
renders the loading one inside the frame it sends.
|
|
5
|
+
|
|
6
|
+
Locals: state (:loading or :failed), title (the name when known). %>
|
|
7
|
+
<section class="chats-window__card" role="region" aria-label="<%= local_assigns[:title] || t("chats.dock.title") %>">
|
|
8
|
+
<%= render "chats/dock/window_header", title: local_assigns[:title] || t("chats.thread.loading") %>
|
|
9
|
+
<% if local_assigns[:state] == :failed %>
|
|
10
|
+
<div class="chats-window__status" role="alert">
|
|
11
|
+
<p><%= t("chats.dock.load_error") %></p>
|
|
12
|
+
<button type="button" class="chats-window__retry" data-action="chats--dock#retry"><%= t("chats.dock.retry") %></button>
|
|
13
|
+
</div>
|
|
14
|
+
<% else %>
|
|
15
|
+
<div class="chats-window__status"><%= t("chats.thread.loading") %></div>
|
|
16
|
+
<% end %>
|
|
17
|
+
</section>
|
|
@@ -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,67 +1,95 @@
|
|
|
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
|
-
<%= form_with model: Chats::Message.new,
|
|
6
|
-
url: conversation_messages_path(conversation),
|
|
7
|
-
class: "chats-composer",
|
|
8
|
-
data: {
|
|
9
|
-
controller: "chats--composer",
|
|
10
|
-
"chats--composer-typing-url-value": (typing_conversation_path(conversation) if Chats.config.typing_indicators),
|
|
11
|
-
action: "turbo:submit-end->chats--composer#submitted chats:edit-message@window->chats--composer#beginEdit"
|
|
12
|
-
} do |form| %>
|
|
4
|
+
and reset-on-success. Plain form POST still works with JS disabled.
|
|
13
5
|
|
|
14
|
-
|
|
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 %>
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
</div>
|
|
25
|
-
<button type="button"
|
|
26
|
-
class="chats-composer__edit-cancel"
|
|
27
|
-
aria-label="<%= t("chats.composer.cancel_edit") %>"
|
|
28
|
-
data-action="chats--composer#cancelEdit">×</button>
|
|
29
|
-
</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| %>
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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>
|
|
38
47
|
|
|
39
|
-
|
|
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. %>
|
|
40
52
|
<% if Chats.config.attachments %>
|
|
41
|
-
<
|
|
42
|
-
<%= form.file_field :files, multiple: true, hidden: true,
|
|
43
|
-
accept: (Chats.config.attachments == :images ? "image/*" : nil),
|
|
44
|
-
data: { "chats--composer-target": "files", action: "change->chats--composer#filesChanged" } %>
|
|
45
|
-
<span aria-hidden="true">📎</span>
|
|
46
|
-
<span class="chats-composer__file-count" data-chats--composer-target="fileCount" hidden></span>
|
|
47
|
-
</label>
|
|
53
|
+
<div class="chats-composer__previews" data-chats--composer-target="previews" hidden></div>
|
|
48
54
|
<% end %>
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
id: dom_id(conversation, :message_files),
|
|
61
|
+
accept: (Chats.config.attachments == :images ? "image/*" : nil),
|
|
62
|
+
data: { "chats--composer-target": "files", action: "change->chats--composer#filesChanged" } %>
|
|
63
|
+
<span aria-hidden="true">📎</span>
|
|
64
|
+
<span class="chats-composer__file-count" data-chats--composer-target="fileCount" hidden></span>
|
|
65
|
+
</label>
|
|
66
|
+
<% end %>
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
<%# Ids per conversation, not form_with's `message_body`: a page can
|
|
69
|
+
hold several composers (the dock's windows), and Turbo restores
|
|
70
|
+
focus BY ID after rendering a stream — a shared id sent the cursor
|
|
71
|
+
into another window's composer. %>
|
|
72
|
+
<%= form.text_area :body,
|
|
73
|
+
id: dom_id(conversation, :message_body),
|
|
74
|
+
rows: 1,
|
|
75
|
+
placeholder: t("chats.composer.placeholder"),
|
|
76
|
+
class: "chats-composer__input",
|
|
77
|
+
autocomplete: "off",
|
|
78
|
+
data: {
|
|
79
|
+
"chats--composer-target": "input",
|
|
80
|
+
# In a dock window, the dock puts a saved draft back the moment
|
|
81
|
+
# this field appears (dock_controller.js, composerTargetConnected).
|
|
82
|
+
"chats--dock-target": "composer",
|
|
83
|
+
action: "input->chats--composer#typed keydown.enter->chats--composer#enterSend " \
|
|
84
|
+
"keydown.esc->chats--composer#escape"
|
|
85
|
+
} %>
|
|
86
|
+
|
|
87
|
+
<%# pointerdown/mousedown keepFocus: keeps the textarea focused (and the
|
|
88
|
+
mobile keyboard up) through the send tap — see composer controller. %>
|
|
89
|
+
<%= form.button type: :submit, class: "chats-composer__send", "aria-label": t("chats.composer.send"),
|
|
90
|
+
data: { action: "pointerdown->chats--composer#keepFocus mousedown->chats--composer#keepFocus" } do %>
|
|
91
|
+
<span aria-hidden="true">➤</span>
|
|
92
|
+
<% end %>
|
|
93
|
+
</div>
|
|
94
|
+
<% end %>
|
|
95
|
+
</div>
|
|
@@ -15,19 +15,24 @@
|
|
|
15
15
|
data-chats--thread-target="message"
|
|
16
16
|
data-sender-key="<%= message.sender_key %>"
|
|
17
17
|
data-timestamp="<%= message.created_at.iso8601 %>"
|
|
18
|
-
<%#
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
data-
|
|
18
|
+
<%# The stale-thread catch-up's cursors, to the microsecond the
|
|
19
|
+
database keeps, with the id that breaks a tie: the newest creation
|
|
20
|
+
on screen decides what is appended (`after`), the newest change
|
|
21
|
+
what is replaced (`changed`), and the oldest bubble's creation is
|
|
22
|
+
the `from` of the list of what still exists (hard deletes). %>
|
|
23
|
+
data-message-id="<%= message.id %>"
|
|
24
|
+
data-created-at-us="<%= (message.created_at.to_i * 1_000_000) + message.created_at.usec %>"
|
|
25
|
+
data-updated-at-us="<%= (message.updated_at.to_i * 1_000_000) + message.updated_at.usec %>">
|
|
24
26
|
|
|
25
27
|
<% if message.system? %>
|
|
26
|
-
<div class="chats-message__system"
|
|
28
|
+
<div class="chats-message__system<%= " chats-message__system--with-action" if message.action? %>">
|
|
29
|
+
<%= message.body %>
|
|
30
|
+
<%= render "chats/messages/action", action: message.action if message.action? %>
|
|
31
|
+
</div>
|
|
27
32
|
<% else %>
|
|
28
33
|
<div class="chats-message__content">
|
|
29
34
|
<% if message.conversation.group? && message.sender %>
|
|
30
|
-
|
|
35
|
+
<%= chats_messager_name(message.sender, css_class: "chats-message__sender") %>
|
|
31
36
|
<% end %>
|
|
32
37
|
|
|
33
38
|
<div class="chats-message__bubble-row">
|
|
@@ -74,6 +79,16 @@
|
|
|
74
79
|
<% end %>
|
|
75
80
|
</div>
|
|
76
81
|
<% end %>
|
|
82
|
+
|
|
83
|
+
<%= render "chats/messages/action", action: message.action if message.action? %>
|
|
84
|
+
<% end %>
|
|
85
|
+
|
|
86
|
+
<%# Signature: who WROTE this, when that isn't who it was sent
|
|
87
|
+
from — an agent answering from a shared desk seat. The sender
|
|
88
|
+
stays the conversation identity; this line keeps the human
|
|
89
|
+
visible. See Chats::Message#signed?. %>
|
|
90
|
+
<% if (signature = chats_message_signature(message)) %>
|
|
91
|
+
<div class="chats-message__signature" data-chats-message-signature><%= signature %></div>
|
|
77
92
|
<% end %>
|
|
78
93
|
|
|
79
94
|
<span class="chats-message__meta">
|
|
@@ -81,6 +96,7 @@
|
|
|
81
96
|
<span class="chats-message__edited"><%= t("chats.message.edited") %></span>
|
|
82
97
|
<% end %>
|
|
83
98
|
<time datetime="<%= message.created_at.iso8601 %>"><%= message.created_at.in_time_zone.strftime("%H:%M") %></time>
|
|
99
|
+
<%= chats_slot :message_meta, message: message %>
|
|
84
100
|
<% if Chats.config.read_receipts %>
|
|
85
101
|
<span class="chats-message__receipt"
|
|
86
102
|
data-chats-message-receipt
|
|
@@ -95,6 +111,12 @@
|
|
|
95
111
|
</div>
|
|
96
112
|
|
|
97
113
|
<% unless message.deleted? %>
|
|
114
|
+
<%# A locked conversation (its SUBJECT says so) refuses every write —
|
|
115
|
+
new messages, edits, deletes and reactions alike. The server
|
|
116
|
+
enforces it; here we stop OFFERING what would only 422: existing
|
|
117
|
+
reactions still render, as plain counts instead of toggles, and
|
|
118
|
+
the long-press menu keeps only Copy. %>
|
|
119
|
+
<% locked = message.conversation.locked? %>
|
|
98
120
|
<% reactions = Chats.config.reactions ? Chats::Reaction.summary_for(message) : [] %>
|
|
99
121
|
<% if reactions.any? %>
|
|
100
122
|
<div class="chats-message__reactions">
|
|
@@ -103,10 +125,16 @@
|
|
|
103
125
|
through the HOST's renderer where engine helpers don't exist
|
|
104
126
|
unqualified. See EngineHelper#chats_routes. %>
|
|
105
127
|
<% reactions.each do |emoji, count| %>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
128
|
+
<% if locked %>
|
|
129
|
+
<span class="chats-reaction chats-reaction--locked">
|
|
130
|
+
<%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
|
|
131
|
+
</span>
|
|
132
|
+
<% else %>
|
|
133
|
+
<%= button_to chats_routes.conversation_message_reactions_path(message.conversation, message),
|
|
134
|
+
method: :post, params: { emoji: emoji },
|
|
135
|
+
class: "chats-reaction", "aria-label": t("chats.message.toggle_reaction", emoji: emoji) do %>
|
|
136
|
+
<%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
|
|
137
|
+
<% end %>
|
|
110
138
|
<% end %>
|
|
111
139
|
<% end %>
|
|
112
140
|
</div>
|
|
@@ -122,7 +150,7 @@
|
|
|
122
150
|
button_to forms keep working when cloned: the CSRF token is
|
|
123
151
|
baked in at render time. %>
|
|
124
152
|
<template data-chats-message-menu>
|
|
125
|
-
<% if Chats.config.reactions %>
|
|
153
|
+
<% if Chats.config.reactions && !locked %>
|
|
126
154
|
<div class="chats-popup__reactions" role="group" aria-label="<%= t("chats.message.react") %>">
|
|
127
155
|
<% %w[👍 ❤️ 😂 😮 😢 🙏].each do |emoji| %>
|
|
128
156
|
<%= button_to emoji, chats_routes.conversation_message_reactions_path(message.conversation, message),
|
|
@@ -137,12 +165,12 @@
|
|
|
137
165
|
<%= t("chats.message.copy") %>
|
|
138
166
|
</button>
|
|
139
167
|
<% end %>
|
|
140
|
-
<% if Chats.config.editing && message.body.present? %>
|
|
168
|
+
<% if Chats.config.editing && message.body.present? && !locked %>
|
|
141
169
|
<button type="button" class="chats-popup__item" role="menuitem" data-chats-action="edit" data-chats-own-only>
|
|
142
170
|
<%= t("chats.message.edit") %>
|
|
143
171
|
</button>
|
|
144
172
|
<% end %>
|
|
145
|
-
<% if Chats.config.deletion %>
|
|
173
|
+
<% if Chats.config.deletion && !locked %>
|
|
146
174
|
<%= button_to t("chats.message.delete"),
|
|
147
175
|
chats_routes.conversation_message_path(message.conversation, message),
|
|
148
176
|
method: :delete,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# The send landed on a conversation whose subject has since locked it.
|
|
2
|
+
Replace the composer with the locked notice (422) so the screen stops
|
|
3
|
+
lying to whoever had it open, and clear any stale error text. %>
|
|
4
|
+
<%= turbo_stream.replace dom_id(@conversation, :composer) do %>
|
|
5
|
+
<%= render "chats/conversations/locked_composer", conversation: @conversation %>
|
|
6
|
+
<% end %>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<%# The live unread badge (embed anywhere via the chats_unread_badge helper).
|
|
2
2
|
Fixed id: it's the target Chats::Broadcasts.update_badge_of replaces.
|
|
3
|
-
Always rendered (hidden at zero) so the broadcast has an element to update.
|
|
3
|
+
Always rendered (hidden at zero) so the broadcast has an element to update.
|
|
4
|
+
data-chats-unread-badge marks every unread badge, the dock's included, for
|
|
5
|
+
host CSS and JS that want them all. %>
|
|
4
6
|
<span id="chats_unread_badge"
|
|
5
7
|
class="chats-badge chats-badge--floating"
|
|
8
|
+
data-chats-unread-badge
|
|
6
9
|
<%= "hidden" unless count.positive? %>><%= count > 99 ? "99+" : count %></span>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%# The "official account" mark. Rendered next to a verified messager's name
|
|
2
|
+
(`acts_as_messager verified: true`) in every bundled view that shows one:
|
|
3
|
+
the inbox row, the stacked row and the thread header.
|
|
4
|
+
|
|
5
|
+
It is an IMAGE with a name, not decoration: the wrapper carries
|
|
6
|
+
role="img" plus the localized label, so a screen reader announces
|
|
7
|
+
"Cuenta oficial" / "Official account" where a sighted person sees the
|
|
8
|
+
badge, and the glyph itself is aria-hidden so it is never read twice.
|
|
9
|
+
|
|
10
|
+
The glyph is heroicons `check-badge` (24/solid): a scalloped rosette
|
|
11
|
+
with a TICK knocked out of it, which is the mark people already read as
|
|
12
|
+
"verified". It is deliberately not a plain star — a star says "favourite"
|
|
13
|
+
or "rated", and at 14px a solid star and a solid rosette are the same
|
|
14
|
+
blob, so the tick is the whole point. `fill-rule: evenodd` is what knocks
|
|
15
|
+
the tick out; without it the rosette fills solid and the mark is a blob.
|
|
16
|
+
|
|
17
|
+
Colour comes from the --chats-verified CSS variable (see chats.css) via
|
|
18
|
+
`fill: currentColor` — override that variable, or swap this whole partial
|
|
19
|
+
with `config.verified_badge`. %>
|
|
20
|
+
<span class="chats-verified"
|
|
21
|
+
role="img"
|
|
22
|
+
title="<%= t("chats.verified.label") %>"
|
|
23
|
+
aria-label="<%= t("chats.verified.label") %>">
|
|
24
|
+
<svg xmlns="http://www.w3.org/2000/svg"
|
|
25
|
+
viewBox="0 0 24 24"
|
|
26
|
+
fill="currentColor"
|
|
27
|
+
class="chats-verified__glyph"
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
focusable="false">
|
|
30
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" />
|
|
31
|
+
</svg>
|
|
32
|
+
</span>
|
data/config/importmap.rb
CHANGED
|
@@ -8,9 +8,18 @@
|
|
|
8
8
|
# "chats--composer", etc. with ZERO host JavaScript changes. (stimulus-rails,
|
|
9
9
|
# app/assets/javascripts/stimulus-loading.js, registerControllerFromPath.)
|
|
10
10
|
#
|
|
11
|
-
# Hosts can override
|
|
11
|
+
# Hosts can override any controller by pinning the same key themselves —
|
|
12
12
|
# the engine's importmap is drawn FIRST (unshifted in Chats::Engine), and
|
|
13
13
|
# importmap-rails resolves duplicate pins last-wins.
|
|
14
14
|
pin "controllers/chats/thread_controller", to: "chats/thread_controller.js"
|
|
15
15
|
pin "controllers/chats/composer_controller", to: "chats/composer_controller.js"
|
|
16
16
|
pin "controllers/chats/debounced_submit_controller", to: "chats/debounced_submit_controller.js"
|
|
17
|
+
pin "controllers/chats/refresh_inbox_controller", to: "chats/refresh_inbox_controller.js"
|
|
18
|
+
pin "controllers/chats/dock_controller", to: "chats/dock_controller.js"
|
|
19
|
+
pin "controllers/chats/unread_badge_controller", to: "chats/unread_badge_controller.js"
|
|
20
|
+
pin "controllers/chats/split_controller", to: "chats/split_controller.js"
|
|
21
|
+
|
|
22
|
+
# Not a controller: what the inbox, thread, badge and dock controllers share
|
|
23
|
+
# to catch up on broadcasts they missed. Outside controllers/ so the eager
|
|
24
|
+
# loader never tries to register it.
|
|
25
|
+
pin "chats/recovery", to: "chats/recovery.js"
|
data/config/locales/en.yml
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
en:
|
|
2
2
|
chats:
|
|
3
|
+
page_title:
|
|
4
|
+
inbox: "Messages"
|
|
5
|
+
conversation: "%{name} · Messages"
|
|
3
6
|
inbox:
|
|
4
7
|
title: "Messages"
|
|
5
8
|
search_placeholder: "Search conversations"
|
|
@@ -8,6 +11,12 @@ en:
|
|
|
8
11
|
no_results_title: "No results"
|
|
9
12
|
no_results_hint: "Nothing matched “%{query}”."
|
|
10
13
|
no_messages: "No messages yet"
|
|
14
|
+
select_hint: "Select a conversation"
|
|
15
|
+
filtered_by: "Conversations with %{name}"
|
|
16
|
+
clear_filter: "Show all conversations"
|
|
17
|
+
group_count:
|
|
18
|
+
one: "1 conversation"
|
|
19
|
+
other: "%{count} conversations"
|
|
11
20
|
you_prefix: "You:"
|
|
12
21
|
thread:
|
|
13
22
|
back: "Back"
|
|
@@ -22,14 +31,18 @@ en:
|
|
|
22
31
|
yesterday: "Yesterday"
|
|
23
32
|
typing_suffix: "is typing…"
|
|
24
33
|
new_messages: "New messages"
|
|
34
|
+
see_all: "See all"
|
|
25
35
|
conversation:
|
|
26
36
|
empty_title: "Conversation"
|
|
37
|
+
verified:
|
|
38
|
+
label: "Official account"
|
|
27
39
|
message:
|
|
28
40
|
deleted: "Message deleted"
|
|
29
41
|
edited: "edited"
|
|
30
42
|
copy: "Copy"
|
|
31
43
|
copied: "Copied!"
|
|
32
44
|
attachment: "Photo"
|
|
45
|
+
signature: "— %{name}"
|
|
33
46
|
close_attachment: "Close photo"
|
|
34
47
|
edit: "Edit"
|
|
35
48
|
delete: "Delete"
|
|
@@ -44,6 +57,24 @@ en:
|
|
|
44
57
|
placeholder: "Write a message…"
|
|
45
58
|
send: "Send"
|
|
46
59
|
attach: "Attach images"
|
|
60
|
+
locked: "This conversation is closed."
|
|
61
|
+
dock:
|
|
62
|
+
title: "Messages"
|
|
63
|
+
unread_suffix: "unread"
|
|
64
|
+
see_all: "See all messages"
|
|
65
|
+
window_label: "Conversation with %{name}"
|
|
66
|
+
open_full: "Open full conversation"
|
|
67
|
+
minimize: "Minimize"
|
|
68
|
+
close: "Close conversation"
|
|
69
|
+
back: "Back to messages"
|
|
70
|
+
hello: "Say hello to %{name}."
|
|
71
|
+
load_error: "Couldn't load this conversation."
|
|
72
|
+
retry: "Try again"
|
|
73
|
+
new_message_from: "New message from %{name}"
|
|
74
|
+
window_unread:
|
|
75
|
+
one: "%{count} new message"
|
|
76
|
+
other: "%{count} new messages"
|
|
77
|
+
muted: "Muted"
|
|
47
78
|
buttons:
|
|
48
79
|
chat: "Message"
|
|
49
80
|
flashes:
|
|
@@ -54,6 +85,9 @@ en:
|
|
|
54
85
|
unmuted: "Conversation unmuted."
|
|
55
86
|
|
|
56
87
|
activerecord:
|
|
88
|
+
attributes:
|
|
89
|
+
chats/message:
|
|
90
|
+
action: "Action"
|
|
57
91
|
errors:
|
|
58
92
|
models:
|
|
59
93
|
chats/conversation:
|
|
@@ -67,7 +101,13 @@ en:
|
|
|
67
101
|
chats/message:
|
|
68
102
|
attributes:
|
|
69
103
|
base:
|
|
104
|
+
locked: "This conversation is closed."
|
|
70
105
|
blocked: "You can't message this person."
|
|
106
|
+
not_allowed: "You can't message this person right now."
|
|
107
|
+
action:
|
|
108
|
+
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 /"
|
|
109
|
+
author:
|
|
110
|
+
not_a_messager: "must be a messager (acts_as_messager)"
|
|
71
111
|
sender:
|
|
72
112
|
blank: "is required"
|
|
73
113
|
not_a_participant: "is not a participant of this conversation"
|
data/config/locales/es.yml
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
es:
|
|
2
2
|
chats:
|
|
3
|
+
page_title:
|
|
4
|
+
inbox: "Mensajes"
|
|
5
|
+
conversation: "%{name} · Mensajes"
|
|
3
6
|
inbox:
|
|
4
7
|
title: "Mensajes"
|
|
5
8
|
search_placeholder: "Buscar conversaciones"
|
|
@@ -8,6 +11,12 @@ es:
|
|
|
8
11
|
no_results_title: "Sin resultados"
|
|
9
12
|
no_results_hint: "No hay nada que coincida con «%{query}»."
|
|
10
13
|
no_messages: "Sin mensajes todavía"
|
|
14
|
+
select_hint: "Selecciona una conversación"
|
|
15
|
+
filtered_by: "Conversaciones con %{name}"
|
|
16
|
+
clear_filter: "Ver todas las conversaciones"
|
|
17
|
+
group_count:
|
|
18
|
+
one: "1 conversación"
|
|
19
|
+
other: "%{count} conversaciones"
|
|
11
20
|
you_prefix: "Tú:"
|
|
12
21
|
thread:
|
|
13
22
|
back: "Atrás"
|
|
@@ -22,14 +31,18 @@ es:
|
|
|
22
31
|
yesterday: "Ayer"
|
|
23
32
|
typing_suffix: "está escribiendo…"
|
|
24
33
|
new_messages: "Mensajes nuevos"
|
|
34
|
+
see_all: "Ver todas"
|
|
25
35
|
conversation:
|
|
26
36
|
empty_title: "Conversación"
|
|
37
|
+
verified:
|
|
38
|
+
label: "Cuenta oficial"
|
|
27
39
|
message:
|
|
28
40
|
deleted: "Mensaje eliminado"
|
|
29
41
|
edited: "editado"
|
|
30
42
|
copy: "Copiar"
|
|
31
43
|
copied: "¡Copiado!"
|
|
32
44
|
attachment: "Foto"
|
|
45
|
+
signature: "— %{name}"
|
|
33
46
|
close_attachment: "Cerrar foto"
|
|
34
47
|
edit: "Editar"
|
|
35
48
|
delete: "Eliminar"
|
|
@@ -44,6 +57,24 @@ es:
|
|
|
44
57
|
placeholder: "Escribe un mensaje…"
|
|
45
58
|
send: "Enviar"
|
|
46
59
|
attach: "Adjuntar imágenes"
|
|
60
|
+
locked: "Esta conversación está cerrada."
|
|
61
|
+
dock:
|
|
62
|
+
title: "Mensajes"
|
|
63
|
+
unread_suffix: "sin leer"
|
|
64
|
+
see_all: "Ver todos los mensajes"
|
|
65
|
+
window_label: "Conversación con %{name}"
|
|
66
|
+
open_full: "Abrir la conversación completa"
|
|
67
|
+
minimize: "Minimizar"
|
|
68
|
+
close: "Cerrar conversación"
|
|
69
|
+
back: "Volver a los mensajes"
|
|
70
|
+
hello: "Saluda a %{name}."
|
|
71
|
+
load_error: "No se ha podido cargar esta conversación."
|
|
72
|
+
retry: "Reintentar"
|
|
73
|
+
new_message_from: "Nuevo mensaje de %{name}"
|
|
74
|
+
window_unread:
|
|
75
|
+
one: "%{count} mensaje nuevo"
|
|
76
|
+
other: "%{count} mensajes nuevos"
|
|
77
|
+
muted: "Silenciada"
|
|
47
78
|
buttons:
|
|
48
79
|
chat: "Mensaje"
|
|
49
80
|
flashes:
|
|
@@ -54,6 +85,9 @@ es:
|
|
|
54
85
|
unmuted: "Notificaciones activadas."
|
|
55
86
|
|
|
56
87
|
activerecord:
|
|
88
|
+
attributes:
|
|
89
|
+
chats/message:
|
|
90
|
+
action: "Acción"
|
|
57
91
|
errors:
|
|
58
92
|
models:
|
|
59
93
|
chats/conversation:
|
|
@@ -67,7 +101,13 @@ es:
|
|
|
67
101
|
chats/message:
|
|
68
102
|
attributes:
|
|
69
103
|
base:
|
|
104
|
+
locked: "Esta conversación está cerrada."
|
|
70
105
|
blocked: "No puedes enviar mensajes a esta persona."
|
|
106
|
+
not_allowed: "Ahora mismo no puedes enviar mensajes a esta persona."
|
|
107
|
+
action:
|
|
108
|
+
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 /"
|
|
109
|
+
author:
|
|
110
|
+
not_a_messager: "debe ser un mensajero (acts_as_messager)"
|
|
71
111
|
sender:
|
|
72
112
|
blank: "es obligatorio"
|
|
73
113
|
not_a_participant: "no participa en esta conversación"
|