chats 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +478 -0
- data/README.md +150 -10
- data/app/assets/stylesheets/chats.css +599 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +273 -55
- data/app/helpers/chats/engine_helper.rb +102 -6
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1106 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/thread_controller.js +317 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- 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/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +133 -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 +24 -10
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- 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 +138 -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/messages/_message.html.erb +8 -6
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +17 -0
- data/config/locales/es.yml +17 -0
- data/config/routes.rb +8 -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 +178 -17
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +57 -0
- data/lib/chats/models/message.rb +98 -9
- data/lib/chats/models/reaction.rb +13 -0
- 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 +23 -2
|
@@ -0,0 +1,138 @@
|
|
|
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-badge-url-value": chats_routes.badge_conversations_path,
|
|
36
|
+
"chats--dock-announcement-value": t("chats.dock.new_message_from", name: "%{name}"),
|
|
37
|
+
# Every plural form the locale has (CLDR: zero, one, two, few, many,
|
|
38
|
+
# other), uninterpolated: the controller picks one with
|
|
39
|
+
# Intl.PluralRules and fills %{count} in.
|
|
40
|
+
"chats--dock-window-unread-value": chats_plural_forms("chats.dock.window_unread").to_json,
|
|
41
|
+
action: "keydown->chats--dock#keydown " \
|
|
42
|
+
"focusin->chats--dock#engage pointerdown->chats--dock#engage wheel->chats--dock#engage " \
|
|
43
|
+
"touchstart->chats--dock#engage " \
|
|
44
|
+
"turbo:before-fetch-response->chats--dock#frameResponded " \
|
|
45
|
+
"turbo:frame-missing->chats--dock#frameMissing " \
|
|
46
|
+
"turbo:frame-load->chats--dock#frameLoaded " \
|
|
47
|
+
"turbo:fetch-request-error->chats--dock#frameFailed " \
|
|
48
|
+
"turbo:submit-end->chats--dock#sent " \
|
|
49
|
+
"input->chats--dock#typed " \
|
|
50
|
+
"click@document->chats--dock#delegate " \
|
|
51
|
+
"turbo:before-stream-render@document->chats--dock#beforeStreamRender " \
|
|
52
|
+
"turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged"
|
|
53
|
+
} do %>
|
|
54
|
+
<%= turbo_stream_from viewer, :chats_dock %>
|
|
55
|
+
<%= render "chats/dock/signal" %>
|
|
56
|
+
|
|
57
|
+
<div id="chats_dock_windows" class="chats-dock__windows" data-chats--dock-target="windows"></div>
|
|
58
|
+
|
|
59
|
+
<%# What a window shows before its thread arrives, and if it can't. %>
|
|
60
|
+
<template data-chats--dock-target="loadingShell"><%= render "chats/dock/window_shell", state: :loading %></template>
|
|
61
|
+
<template data-chats--dock-target="failedShell"><%= render "chats/dock/window_shell", state: :failed %></template>
|
|
62
|
+
|
|
63
|
+
<details class="chats-dock__panel"
|
|
64
|
+
data-chats--dock-target="panel"
|
|
65
|
+
data-action="toggle->chats--dock#panelToggled">
|
|
66
|
+
<%# The summary reads "Messages, 3 unread" from its own text; on a phone
|
|
67
|
+
the closed one is the round launcher (chats.css). %>
|
|
68
|
+
<summary class="chats-dock__bar chats-dock__toggle">
|
|
69
|
+
<%= chats_messager_avatar(viewer) %>
|
|
70
|
+
<svg class="chats-dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
71
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
|
72
|
+
<path d="M12 3a9 9 0 0 0-7.8 13.5L3 21l4.6-1.2A9 9 0 1 0 12 3Z" />
|
|
73
|
+
</svg>
|
|
74
|
+
<span class="chats-dock__title"><%= t("chats.dock.title") %></span>
|
|
75
|
+
<%= render "chats/dock/badge", count: viewer.unread_chats_count %>
|
|
76
|
+
<span class="chats-dock__chevron" aria-hidden="true">
|
|
77
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
78
|
+
stroke-linejoin="round" focusable="false"><path d="m6 15 6-6 6 6" /></svg>
|
|
79
|
+
</span>
|
|
80
|
+
<span class="chats-dock__close-glyph" aria-hidden="true">
|
|
81
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
82
|
+
focusable="false"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
|
83
|
+
</span>
|
|
84
|
+
</summary>
|
|
85
|
+
|
|
86
|
+
<div class="chats-dock__body">
|
|
87
|
+
<% if Chats.config.search %>
|
|
88
|
+
<%# The inbox's own search: with JavaScript the results land in the
|
|
89
|
+
dock's frame (the inbox answers that frame with the dock's list);
|
|
90
|
+
without it, on the full inbox. %>
|
|
91
|
+
<%= form_with url: chats_routes.conversations_path,
|
|
92
|
+
method: :get,
|
|
93
|
+
class: "chats-search chats-dock__search",
|
|
94
|
+
# `html:`: form_with drops attributes it doesn't know (role among them).
|
|
95
|
+
html: { role: "search" },
|
|
96
|
+
data: {
|
|
97
|
+
controller: "chats--debounced-submit",
|
|
98
|
+
action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
|
|
99
|
+
"chats--debounced-submit-delay-value": 250,
|
|
100
|
+
turbo_frame: "chats_dock_list"
|
|
101
|
+
} do %>
|
|
102
|
+
<%= hidden_field_tag :recent, recent, id: nil %>
|
|
103
|
+
<%= search_field_tag :q, nil,
|
|
104
|
+
id: "chats_dock_q",
|
|
105
|
+
placeholder: t("chats.inbox.search_placeholder"),
|
|
106
|
+
"aria-label": t("chats.inbox.search_placeholder"),
|
|
107
|
+
class: "chats-search__input",
|
|
108
|
+
autocomplete: "off" %>
|
|
109
|
+
<% end %>
|
|
110
|
+
<% end %>
|
|
111
|
+
|
|
112
|
+
<%# Lazy: nothing is fetched until the panel first opens; until then,
|
|
113
|
+
four still rows. The controller reloads it when the signal says,
|
|
114
|
+
and when its stream may have missed a signal. `refresh: :morph`:
|
|
115
|
+
a reload morphs the rows in place, so the list keeps its scroll
|
|
116
|
+
position, and it reloads its own src, so a search stays. %>
|
|
117
|
+
<%= turbo_frame_tag "chats_dock_list",
|
|
118
|
+
src: chats_routes.dock_conversations_path(recent: recent),
|
|
119
|
+
loading: :lazy,
|
|
120
|
+
refresh: :morph,
|
|
121
|
+
target: "_top",
|
|
122
|
+
class: "chats-dock__results",
|
|
123
|
+
# A click here opens a window, never the page: hovering a row
|
|
124
|
+
# must not prefetch it.
|
|
125
|
+
data: { "chats--dock-target": "list", turbo_prefetch: false } do %>
|
|
126
|
+
<div aria-busy="true" aria-label="<%= t("chats.thread.loading") %>">
|
|
127
|
+
<% 4.times do %><div class="chats-dock__skeleton"><span></span></div><% end %>
|
|
128
|
+
</div>
|
|
129
|
+
<% end %>
|
|
130
|
+
|
|
131
|
+
<%= link_to t("chats.dock.see_all"), chats_routes.conversations_path, class: "chats-dock__see-all" %>
|
|
132
|
+
<%= chats_slot :dock_panel_footer, viewer: viewer %>
|
|
133
|
+
</div>
|
|
134
|
+
</details>
|
|
135
|
+
|
|
136
|
+
<%# "New message from …": polite, one at a time. The badge is not live. %>
|
|
137
|
+
<div class="chats-visually-hidden" aria-live="polite" aria-atomic="true" data-chats--dock-target="announcer"></div>
|
|
138
|
+
<% 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
|
|
@@ -15,12 +15,14 @@
|
|
|
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
28
|
<div class="chats-message__system<%= " chats-message__system--with-action" if message.action? %>">
|
|
@@ -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,10 @@ 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"
|
|
19
|
+
pin "controllers/chats/unread_badge_controller", to: "chats/unread_badge_controller.js"
|
|
20
|
+
|
|
21
|
+
# Not a controller: what the inbox, thread, badge and dock controllers share
|
|
22
|
+
# to catch up on broadcasts they missed. Outside controllers/ so the eager
|
|
23
|
+
# loader never tries to register it.
|
|
24
|
+
pin "chats/recovery", to: "chats/recovery.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:
|
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:
|
data/config/routes.rb
CHANGED
|
@@ -5,6 +5,14 @@ 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
|
+
# Every unread badge on the page, fresh (`chats_unread_badge` and the
|
|
13
|
+
# dock's), for a page that may have missed the badge streams.
|
|
14
|
+
get :badge, on: :collection
|
|
15
|
+
|
|
8
16
|
member do
|
|
9
17
|
post :read # advance the viewer's read horizon (mark as read)
|
|
10
18
|
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