chats 0.4.1 → 0.5.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 +276 -0
- data/README.md +96 -9
- data/app/assets/stylesheets/chats.css +596 -0
- data/app/controllers/chats/conversations_controller.rb +71 -5
- data/app/helpers/chats/engine_helper.rb +75 -2
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1070 -0
- data/app/javascript/chats/thread_controller.js +72 -6
- data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
- data/app/views/chats/conversations/_group.html.erb +6 -2
- data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +172 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +2 -1
- data/app/views/chats/conversations/show.html.erb +11 -118
- 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 +130 -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/_composer.html.erb +11 -1
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +18 -0
- data/config/locales/es.yml +18 -0
- data/config/routes.rb +5 -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/gemfiles/rails_7.1.gemfile +2 -0
- data/gemfiles/rails_7.2.gemfile +2 -0
- data/gemfiles/rails_8.1.gemfile +2 -0
- data/lib/chats/broadcasts.rb +76 -10
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +19 -0
- data/lib/chats/models/message.rb +32 -8
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +18 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<%# The messaging dock (`chats_dock` in the host's layout): fixed to the
|
|
2
|
+
bottom-right corner, LinkedIn / Steam style. Left to right, the floating
|
|
3
|
+
conversation WINDOWS (newest nearest the panel), then the PANEL: its
|
|
4
|
+
header is the collapsed bar (avatar, "Messages", unread badge), and open
|
|
5
|
+
it holds search, the recent list and the way to the full inbox. On a
|
|
6
|
+
phone (chats.css: under 700px wide or 500px tall) the closed bar is a
|
|
7
|
+
round button, the open panel a full-screen sheet.
|
|
8
|
+
|
|
9
|
+
data-turbo-permanent: Turbo Drive carries this element from page to page
|
|
10
|
+
as it is — open windows, scroll positions, drafts and cable
|
|
11
|
+
subscriptions included — as long as the next page renders a dock with
|
|
12
|
+
the same id. The id names the VIEWER, so a visit that lands on another
|
|
13
|
+
account's page (sign out and in, an account switcher) never matches and
|
|
14
|
+
never carries one person's windows into another's page.
|
|
15
|
+
|
|
16
|
+
The panel is a <details>: it opens and closes with no JavaScript at all,
|
|
17
|
+
and its rows, search and links are real links and forms. The chats--dock
|
|
18
|
+
controller (dock_controller.js) does the rest. Its live signal is the
|
|
19
|
+
template on the viewer's own `[messager, :chats_dock]` stream (see
|
|
20
|
+
chats/dock/_signal and Chats::Broadcasts). %>
|
|
21
|
+
<% key = Chats.messager_key(viewer) %>
|
|
22
|
+
<%= chats_styles %>
|
|
23
|
+
<%= tag.aside id: "chats_dock_#{key}",
|
|
24
|
+
class: ["chats-dock", ("chats-dock--no-launcher" unless launcher)],
|
|
25
|
+
"aria-label": t("chats.dock.title"),
|
|
26
|
+
data: {
|
|
27
|
+
turbo_permanent: true,
|
|
28
|
+
chats_messager: key,
|
|
29
|
+
controller: "chats--dock",
|
|
30
|
+
"chats--dock-windows-value": windows,
|
|
31
|
+
"chats--dock-messager-value": key,
|
|
32
|
+
"chats--dock-everywhere-value": everywhere,
|
|
33
|
+
"chats--dock-window-url-value": chats_routes.conversation_path("__id__", window: 1),
|
|
34
|
+
"chats--dock-record-key-value": Chats::Conversation.model_name.param_key,
|
|
35
|
+
"chats--dock-announcement-value": t("chats.dock.new_message_from", name: "%{name}"),
|
|
36
|
+
# Plural forms uninterpolated: the controller fills %{count} in.
|
|
37
|
+
"chats--dock-window-unread-value": I18n.t("chats.dock.window_unread").to_json,
|
|
38
|
+
action: "keydown->chats--dock#keydown " \
|
|
39
|
+
"focusin->chats--dock#engage pointerdown->chats--dock#engage wheel->chats--dock#engage " \
|
|
40
|
+
"touchstart->chats--dock#engage " \
|
|
41
|
+
"turbo:before-fetch-response->chats--dock#frameResponded " \
|
|
42
|
+
"turbo:frame-missing->chats--dock#frameMissing " \
|
|
43
|
+
"turbo:frame-load->chats--dock#frameLoaded " \
|
|
44
|
+
"turbo:fetch-request-error->chats--dock#frameFailed " \
|
|
45
|
+
"turbo:submit-end->chats--dock#sent " \
|
|
46
|
+
"input->chats--dock#typed " \
|
|
47
|
+
"click@document->chats--dock#delegate " \
|
|
48
|
+
"turbo:before-stream-render@document->chats--dock#beforeStreamRender " \
|
|
49
|
+
"turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged"
|
|
50
|
+
} do %>
|
|
51
|
+
<%= turbo_stream_from viewer, :chats_dock %>
|
|
52
|
+
<%= render "chats/dock/signal" %>
|
|
53
|
+
|
|
54
|
+
<div id="chats_dock_windows" class="chats-dock__windows" data-chats--dock-target="windows"></div>
|
|
55
|
+
|
|
56
|
+
<%# What a window shows before its thread arrives, and if it can't. %>
|
|
57
|
+
<template data-chats--dock-target="loadingShell"><%= render "chats/dock/window_shell", state: :loading %></template>
|
|
58
|
+
<template data-chats--dock-target="failedShell"><%= render "chats/dock/window_shell", state: :failed %></template>
|
|
59
|
+
|
|
60
|
+
<details class="chats-dock__panel"
|
|
61
|
+
data-chats--dock-target="panel"
|
|
62
|
+
data-action="toggle->chats--dock#panelToggled">
|
|
63
|
+
<%# The summary reads "Messages, 3 unread" from its own text; on a phone
|
|
64
|
+
the closed one is the round launcher (chats.css). %>
|
|
65
|
+
<summary class="chats-dock__bar chats-dock__toggle">
|
|
66
|
+
<%= chats_messager_avatar(viewer) %>
|
|
67
|
+
<svg class="chats-dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
68
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
|
69
|
+
<path d="M12 3a9 9 0 0 0-7.8 13.5L3 21l4.6-1.2A9 9 0 1 0 12 3Z" />
|
|
70
|
+
</svg>
|
|
71
|
+
<span class="chats-dock__title"><%= t("chats.dock.title") %></span>
|
|
72
|
+
<%= render "chats/dock/badge", count: viewer.unread_chats_count %>
|
|
73
|
+
<span class="chats-dock__chevron" aria-hidden="true">
|
|
74
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
75
|
+
stroke-linejoin="round" focusable="false"><path d="m6 15 6-6 6 6" /></svg>
|
|
76
|
+
</span>
|
|
77
|
+
<span class="chats-dock__close-glyph" aria-hidden="true">
|
|
78
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
79
|
+
focusable="false"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
|
80
|
+
</span>
|
|
81
|
+
</summary>
|
|
82
|
+
|
|
83
|
+
<div class="chats-dock__body">
|
|
84
|
+
<% if Chats.config.search %>
|
|
85
|
+
<%# The inbox's own search: with JavaScript the results land in the
|
|
86
|
+
dock's frame (the inbox answers that frame with the dock's list);
|
|
87
|
+
without it, on the full inbox. %>
|
|
88
|
+
<%= form_with url: chats_routes.conversations_path,
|
|
89
|
+
method: :get,
|
|
90
|
+
class: "chats-search chats-dock__search",
|
|
91
|
+
role: "search",
|
|
92
|
+
data: {
|
|
93
|
+
controller: "chats--debounced-submit",
|
|
94
|
+
action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
|
|
95
|
+
"chats--debounced-submit-delay-value": 250,
|
|
96
|
+
turbo_frame: "chats_dock_list"
|
|
97
|
+
} do %>
|
|
98
|
+
<%= hidden_field_tag :recent, recent, id: nil %>
|
|
99
|
+
<%= search_field_tag :q, nil,
|
|
100
|
+
id: "chats_dock_q",
|
|
101
|
+
placeholder: t("chats.inbox.search_placeholder"),
|
|
102
|
+
"aria-label": t("chats.inbox.search_placeholder"),
|
|
103
|
+
class: "chats-search__input",
|
|
104
|
+
autocomplete: "off" %>
|
|
105
|
+
<% end %>
|
|
106
|
+
<% end %>
|
|
107
|
+
|
|
108
|
+
<%# Lazy: nothing is fetched until the panel first opens; until then,
|
|
109
|
+
four still rows. The controller reloads it when the signal says. %>
|
|
110
|
+
<%= turbo_frame_tag "chats_dock_list",
|
|
111
|
+
src: chats_routes.dock_conversations_path(recent: recent),
|
|
112
|
+
loading: :lazy,
|
|
113
|
+
target: "_top",
|
|
114
|
+
class: "chats-dock__results",
|
|
115
|
+
# A click here opens a window, never the page: hovering a row
|
|
116
|
+
# must not prefetch it.
|
|
117
|
+
data: { "chats--dock-target": "list", turbo_prefetch: false } do %>
|
|
118
|
+
<div aria-busy="true" aria-label="<%= t("chats.thread.loading") %>">
|
|
119
|
+
<% 4.times do %><div class="chats-dock__skeleton"><span></span></div><% end %>
|
|
120
|
+
</div>
|
|
121
|
+
<% end %>
|
|
122
|
+
|
|
123
|
+
<%= link_to t("chats.dock.see_all"), chats_routes.conversations_path, class: "chats-dock__see-all" %>
|
|
124
|
+
<%= chats_slot :dock_panel_footer, viewer: viewer %>
|
|
125
|
+
</div>
|
|
126
|
+
</details>
|
|
127
|
+
|
|
128
|
+
<%# "New message from …": polite, one at a time. The badge is not live. %>
|
|
129
|
+
<div class="chats-visually-hidden" aria-live="polite" aria-atomic="true" data-chats--dock-target="announcer"></div>
|
|
130
|
+
<% end %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%# What just changed, for the dock's controller (it acts when the replaced
|
|
2
|
+
element connects, and consumes data-changed so a Turbo Drive visit that
|
|
3
|
+
reconnects the same node never acts twice). An inert <template>: it
|
|
4
|
+
renders nothing. The dock renders it bare; Chats::Broadcasts sends it
|
|
5
|
+
with the signal:
|
|
6
|
+
|
|
7
|
+
data-changed reload the recent list
|
|
8
|
+
data-conversation-id the conversation it was about, when the
|
|
9
|
+
viewer can see it
|
|
10
|
+
data-notify a message they'd be notified about: its
|
|
11
|
+
window may pop open, and it is announced
|
|
12
|
+
data-sender-name who it is from, for the announcement
|
|
13
|
+
data-unread-count unread there now, for the window's header
|
|
14
|
+
|
|
15
|
+
Strings for people are rendered by the dock in the VIEWER's locale
|
|
16
|
+
(Stimulus values), never here: a broadcast renders in no one's. %>
|
|
17
|
+
<% signal = local_assigns[:signal] %>
|
|
18
|
+
<%= tag.template id: "chats_dock_signal",
|
|
19
|
+
data: {
|
|
20
|
+
"chats--dock-target": "signal",
|
|
21
|
+
changed: (true if signal),
|
|
22
|
+
conversation_id: signal&.dig(:conversation_id),
|
|
23
|
+
notify: (true if signal&.dig(:notify)),
|
|
24
|
+
sender_name: signal&.dig(:sender_name),
|
|
25
|
+
unread_count: signal&.dig(:unread_count)
|
|
26
|
+
} %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%# One dock update (Chats::Broadcasts.update_dock_of), rendered per
|
|
2
|
+
participant and sent on their own `[messager, :chats_dock]` stream:
|
|
3
|
+
the badges when the unread count moved, and the signal. %>
|
|
4
|
+
<% if count %>
|
|
5
|
+
<%= turbo_stream.replace_all ".chats-dock [data-chats-unread-badge]",
|
|
6
|
+
partial: "chats/dock/badge", locals: { count: count } %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% if signal %>
|
|
9
|
+
<%= turbo_stream.replace "chats_dock_signal", partial: "chats/dock/signal", locals: { signal: signal } %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<%# A floating window's FRAME, loading its thread from `?window=1`
|
|
2
|
+
(conversations/window.html.erb). Appended to #chats_dock_windows by
|
|
3
|
+
ConversationsController#create (`chat_button_to …, dock: true`); the
|
|
4
|
+
dock's controller builds the identical element itself when a row or a
|
|
5
|
+
live message opens one (dock_controller.js, buildWindow) — keep the two
|
|
6
|
+
in step. A conversation already open in a window is focused instead of
|
|
7
|
+
reloaded (the controller intercepts the append).
|
|
8
|
+
|
|
9
|
+
`target: "_top"`: links inside a window open the page, never the frame.
|
|
10
|
+
`data-chats-active`: the viewer opened it, so it may mark messages read.
|
|
11
|
+
`data-focus`: and the composer takes the focus once the thread arrives. %>
|
|
12
|
+
<%= turbo_frame_tag dom_id(conversation, :window),
|
|
13
|
+
src: chats_routes.conversation_path(conversation, window: 1),
|
|
14
|
+
target: "_top",
|
|
15
|
+
class: "chats-window",
|
|
16
|
+
data: {
|
|
17
|
+
"chats--dock-target": "window",
|
|
18
|
+
conversation_id: conversation.id,
|
|
19
|
+
chats_active: true,
|
|
20
|
+
focus: (true if local_assigns[:focus])
|
|
21
|
+
} do %>
|
|
22
|
+
<%= render "chats/dock/window_shell", state: :loading, title: conversation.title_for(chats_current_messager) %>
|
|
23
|
+
<% end %>
|
|
@@ -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>
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
<% if Chats.config.attachments %>
|
|
58
58
|
<label class="chats-composer__attach" aria-label="<%= t("chats.composer.attach") %>">
|
|
59
59
|
<%= form.file_field :files, multiple: true, hidden: true,
|
|
60
|
+
id: dom_id(conversation, :message_files),
|
|
60
61
|
accept: (Chats.config.attachments == :images ? "image/*" : nil),
|
|
61
62
|
data: { "chats--composer-target": "files", action: "change->chats--composer#filesChanged" } %>
|
|
62
63
|
<span aria-hidden="true">📎</span>
|
|
@@ -64,14 +65,23 @@
|
|
|
64
65
|
</label>
|
|
65
66
|
<% end %>
|
|
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. %>
|
|
67
72
|
<%= form.text_area :body,
|
|
73
|
+
id: dom_id(conversation, :message_body),
|
|
68
74
|
rows: 1,
|
|
69
75
|
placeholder: t("chats.composer.placeholder"),
|
|
70
76
|
class: "chats-composer__input",
|
|
71
77
|
autocomplete: "off",
|
|
72
78
|
data: {
|
|
73
79
|
"chats--composer-target": "input",
|
|
74
|
-
|
|
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"
|
|
75
85
|
} %>
|
|
76
86
|
|
|
77
87
|
<%# pointerdown/mousedown keepFocus: keeps the textarea focused (and the
|
|
@@ -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>
|
data/config/importmap.rb
CHANGED
|
@@ -15,3 +15,4 @@ 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
17
|
pin "controllers/chats/refresh_inbox_controller", to: "chats/refresh_inbox_controller.js"
|
|
18
|
+
pin "controllers/chats/dock_controller", to: "chats/dock_controller.js"
|
data/config/locales/en.yml
CHANGED
|
@@ -54,6 +54,23 @@ en:
|
|
|
54
54
|
send: "Send"
|
|
55
55
|
attach: "Attach images"
|
|
56
56
|
locked: "This conversation is closed."
|
|
57
|
+
dock:
|
|
58
|
+
title: "Messages"
|
|
59
|
+
unread_suffix: "unread"
|
|
60
|
+
see_all: "See all messages"
|
|
61
|
+
window_label: "Conversation with %{name}"
|
|
62
|
+
open_full: "Open full conversation"
|
|
63
|
+
minimize: "Minimize"
|
|
64
|
+
close: "Close conversation"
|
|
65
|
+
back: "Back to messages"
|
|
66
|
+
hello: "Say hello to %{name}."
|
|
67
|
+
load_error: "Couldn't load this conversation."
|
|
68
|
+
retry: "Try again"
|
|
69
|
+
new_message_from: "New message from %{name}"
|
|
70
|
+
window_unread:
|
|
71
|
+
one: "%{count} new message"
|
|
72
|
+
other: "%{count} new messages"
|
|
73
|
+
muted: "Muted"
|
|
57
74
|
buttons:
|
|
58
75
|
chat: "Message"
|
|
59
76
|
flashes:
|
|
@@ -82,6 +99,7 @@ en:
|
|
|
82
99
|
base:
|
|
83
100
|
locked: "This conversation is closed."
|
|
84
101
|
blocked: "You can't message this person."
|
|
102
|
+
not_allowed: "You can't message this person right now."
|
|
85
103
|
action:
|
|
86
104
|
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 /"
|
|
87
105
|
author:
|
data/config/locales/es.yml
CHANGED
|
@@ -54,6 +54,23 @@ es:
|
|
|
54
54
|
send: "Enviar"
|
|
55
55
|
attach: "Adjuntar imágenes"
|
|
56
56
|
locked: "Esta conversación está cerrada."
|
|
57
|
+
dock:
|
|
58
|
+
title: "Mensajes"
|
|
59
|
+
unread_suffix: "sin leer"
|
|
60
|
+
see_all: "Ver todos los mensajes"
|
|
61
|
+
window_label: "Conversación con %{name}"
|
|
62
|
+
open_full: "Abrir la conversación completa"
|
|
63
|
+
minimize: "Minimizar"
|
|
64
|
+
close: "Cerrar conversación"
|
|
65
|
+
back: "Volver a los mensajes"
|
|
66
|
+
hello: "Saluda a %{name}."
|
|
67
|
+
load_error: "No se ha podido cargar esta conversación."
|
|
68
|
+
retry: "Reintentar"
|
|
69
|
+
new_message_from: "Nuevo mensaje de %{name}"
|
|
70
|
+
window_unread:
|
|
71
|
+
one: "%{count} mensaje nuevo"
|
|
72
|
+
other: "%{count} mensajes nuevos"
|
|
73
|
+
muted: "Silenciada"
|
|
57
74
|
buttons:
|
|
58
75
|
chat: "Mensaje"
|
|
59
76
|
flashes:
|
|
@@ -82,6 +99,7 @@ es:
|
|
|
82
99
|
base:
|
|
83
100
|
locked: "Esta conversación está cerrada."
|
|
84
101
|
blocked: "No puedes enviar mensajes a esta persona."
|
|
102
|
+
not_allowed: "Ahora mismo no puedes enviar mensajes a esta persona."
|
|
85
103
|
action:
|
|
86
104
|
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 /"
|
|
87
105
|
author:
|
data/config/routes.rb
CHANGED
|
@@ -5,6 +5,11 @@ Chats::Engine.routes.draw do
|
|
|
5
5
|
# `mount Chats::Engine => "/messages"` the inbox is /messages, a thread is
|
|
6
6
|
# /messages/:id, sending is POST /messages/:conversation_id/messages.
|
|
7
7
|
resources :conversations, path: "", only: %i[index show create] do
|
|
8
|
+
# The messaging dock's recent list (`chats_dock`), served into its turbo
|
|
9
|
+
# frame. A collection route, so it is drawn before /:id and never read as
|
|
10
|
+
# a conversation id.
|
|
11
|
+
get :dock, on: :collection
|
|
12
|
+
|
|
8
13
|
member do
|
|
9
14
|
post :read # advance the viewer's read horizon (mark as read)
|
|
10
15
|
post :typing # ephemeral "X is typing…" ping (see Chats::Broadcasts.typing)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/gemfiles/rails_7.1.gemfile
CHANGED
data/gemfiles/rails_7.2.gemfile
CHANGED
data/gemfiles/rails_8.1.gemfile
CHANGED
data/lib/chats/broadcasts.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "action_view/record_identifier"
|
|
|
4
4
|
|
|
5
5
|
module Chats
|
|
6
6
|
# ALL of the gem's real-time fan-out, in one file — read this and you know
|
|
7
|
-
# the entire live behavior.
|
|
7
|
+
# the entire live behavior. Four streams exist:
|
|
8
8
|
#
|
|
9
9
|
# 1. The CONVERSATION stream (`turbo_stream_from @conversation` on the
|
|
10
10
|
# thread page): surgical message append/replace/remove, the read-state
|
|
@@ -30,6 +30,23 @@ module Chats
|
|
|
30
30
|
# Deliberately separate from the inbox stream — if badges rode on it,
|
|
31
31
|
# every page embedding a badge would full-refresh on every message.
|
|
32
32
|
#
|
|
33
|
+
# 4. The per-messager DOCK stream (`[messager, :chats_dock]`, subscribed
|
|
34
|
+
# by the messaging dock — `chats_dock`). One render per participant
|
|
35
|
+
# per event (chats/dock/_update), carrying up to two things:
|
|
36
|
+
# * the dock's BADGES, with the same count as stream 3, whenever the
|
|
37
|
+
# badge moves (a message for them, a read of theirs);
|
|
38
|
+
# * the SIGNAL (chats/dock/_signal), replaced so the dock's
|
|
39
|
+
# controller sees it connect: reload the recent list, and — for a
|
|
40
|
+
# new message the participant should hear about — which
|
|
41
|
+
# conversation it was, from whom, how much is unread there, and
|
|
42
|
+
# that its window may pop open. Senders and edits/deletes get the
|
|
43
|
+
# signal too (their lists reorder), never the pop-up.
|
|
44
|
+
# The hint names a conversation only to a participant who can SEE it
|
|
45
|
+
# (`messager.chats`: blocks and `inbox_scope` applied) — the dock's
|
|
46
|
+
# own list and window endpoints refuse anything else, and the stream
|
|
47
|
+
# must not tell them what those hide. No polling, no Action Cable
|
|
48
|
+
# channel of our own.
|
|
49
|
+
#
|
|
33
50
|
# Everything uses the `_later` variants (rendering happens in background
|
|
34
51
|
# jobs, never inline in the request) except `message_destroyed`, where the
|
|
35
52
|
# record is gone and can't be serialized for a job.
|
|
@@ -38,6 +55,7 @@ module Chats
|
|
|
38
55
|
|
|
39
56
|
MESSAGE_PARTIAL = "chats/messages/message"
|
|
40
57
|
BADGE_PARTIAL = "chats/shared/unread_badge"
|
|
58
|
+
DOCK_UPDATE_PARTIAL = "chats/dock/update"
|
|
41
59
|
READ_STATE_PARTIAL = "chats/conversations/read_state"
|
|
42
60
|
|
|
43
61
|
class << self
|
|
@@ -51,7 +69,7 @@ module Chats
|
|
|
51
69
|
locals: { message: message }
|
|
52
70
|
)
|
|
53
71
|
|
|
54
|
-
fan_out_inboxes(conversation, skip_badge_for: message.sender)
|
|
72
|
+
fan_out_inboxes(conversation, skip_badge_for: message.sender, message: message)
|
|
55
73
|
end
|
|
56
74
|
|
|
57
75
|
def message_updated(message)
|
|
@@ -111,7 +129,17 @@ module Chats
|
|
|
111
129
|
Turbo::StreamsChannel.broadcast_refresh_later_to(messager, :chats_inbox)
|
|
112
130
|
end
|
|
113
131
|
|
|
114
|
-
|
|
132
|
+
# The unread badges of +messager+: the nav badge (stream 3) and the
|
|
133
|
+
# dock's (stream 4), with the signal that tells the dock what changed.
|
|
134
|
+
# +conversation+ and +message+ say what happened (both nil for a
|
|
135
|
+
# read); +participant+ is the messager's seat there.
|
|
136
|
+
def update_badge_of(messager, conversation: nil, message: nil, participant: nil)
|
|
137
|
+
# Counted at broadcast-enqueue time: the badge is advisory UI; a
|
|
138
|
+
# count that's seconds stale self-heals on the next event. The SAME
|
|
139
|
+
# count the page renders (`chats_unread_badge`), so what the inbox
|
|
140
|
+
# scope hides never lights the badge. Counted once for both.
|
|
141
|
+
count = messager.unread_chats_count
|
|
142
|
+
|
|
115
143
|
# REPLACE, not update: the partial renders the whole badge element
|
|
116
144
|
# (its id included) — an `update` would nest it inside the existing
|
|
117
145
|
# element and duplicate the id. Replace keeps it idempotent.
|
|
@@ -119,11 +147,23 @@ module Chats
|
|
|
119
147
|
messager, :chats_badge,
|
|
120
148
|
target: "chats_unread_badge",
|
|
121
149
|
partial: BADGE_PARTIAL,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
150
|
+
locals: { count: count }
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
update_dock_of(messager, count: count, conversation: conversation, message: message, participant: participant)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The dock's update (stream 4): the badges when +count+ is given, and
|
|
157
|
+
# the signal. A conversation the messager cannot see is never named:
|
|
158
|
+
# its badge count is still truthful, the signal is simply not sent.
|
|
159
|
+
def update_dock_of(messager, count: nil, conversation: nil, message: nil, participant: nil)
|
|
160
|
+
signal = dock_signal(messager, conversation, message, participant)
|
|
161
|
+
return if count.nil? && signal.nil?
|
|
162
|
+
|
|
163
|
+
Turbo::StreamsChannel.broadcast_render_later_to(
|
|
164
|
+
messager, :chats_dock,
|
|
165
|
+
partial: DOCK_UPDATE_PARTIAL,
|
|
166
|
+
locals: { count: count, signal: signal }
|
|
127
167
|
)
|
|
128
168
|
end
|
|
129
169
|
|
|
@@ -133,15 +173,41 @@ module Chats
|
|
|
133
173
|
# the sender's — their OTHER tabs/devices need the new ordering; the
|
|
134
174
|
# originating tab skips its own refresh via Turbo's request-id dedup),
|
|
135
175
|
# and every participant except the sender gets a fresh unread badge.
|
|
136
|
-
|
|
176
|
+
# Everyone else — the sender, and every seat on an edit or a delete —
|
|
177
|
+
# gets the dock's list refresh alone.
|
|
178
|
+
def fan_out_inboxes(conversation, skip_badge_for: nil, badges: true, message: nil)
|
|
137
179
|
conversation.participants.active.includes(:messager).each do |participant|
|
|
138
180
|
messager = participant.messager
|
|
139
181
|
next if messager.nil?
|
|
140
182
|
|
|
141
183
|
refresh_inbox_of(messager)
|
|
142
|
-
|
|
184
|
+
if badges && messager != skip_badge_for
|
|
185
|
+
update_badge_of(messager, conversation: conversation, message: message, participant: participant)
|
|
186
|
+
else
|
|
187
|
+
update_dock_of(messager, conversation: conversation)
|
|
188
|
+
end
|
|
143
189
|
end
|
|
144
190
|
end
|
|
191
|
+
|
|
192
|
+
# What the dock is told, or nil for nothing at all. A read (no
|
|
193
|
+
# conversation) only reloads the list. A conversation the messager
|
|
194
|
+
# can't see (blocked, scoped out) is never named. A new message the
|
|
195
|
+
# participant would be notified about (Participant#notifiable_for?:
|
|
196
|
+
# not theirs, not muted, not a headless seat) may pop its window and
|
|
197
|
+
# be announced, with who it is from and how much is unread there.
|
|
198
|
+
def dock_signal(messager, conversation, message, participant)
|
|
199
|
+
return {} if conversation.nil?
|
|
200
|
+
return nil unless messager.chats.exists?(conversation.id)
|
|
201
|
+
|
|
202
|
+
signal = { conversation_id: conversation.id }
|
|
203
|
+
return signal unless message && participant&.notifiable_for?(message)
|
|
204
|
+
|
|
205
|
+
signal.merge(
|
|
206
|
+
notify: true,
|
|
207
|
+
sender_name: message.sender ? Chats.display_name_for(message.sender) : conversation.title_for(messager),
|
|
208
|
+
unread_count: participant.unread_count
|
|
209
|
+
)
|
|
210
|
+
end
|
|
145
211
|
end
|
|
146
212
|
end
|
|
147
213
|
end
|