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
|
@@ -1,25 +1,101 @@
|
|
|
1
1
|
<%# The inbox. Subscribed to the viewer's inbox stream: new activity anywhere
|
|
2
2
|
triggers a Turbo 8 page refresh (morphing, scroll-preserving), which
|
|
3
3
|
re-renders this page per-viewer — see Chats::Broadcasts for why refreshes
|
|
4
|
-
beat surgical row patches here.
|
|
4
|
+
beat surgical row patches here.
|
|
5
|
+
|
|
6
|
+
Two panes (`config.inbox_layout = :split`, the default): the list in a
|
|
7
|
+
<nav> on the left, and on the right the `chats_conversation` frame with
|
|
8
|
+
the open conversation (/messages/:id renders this same template, see
|
|
9
|
+
ConversationsController#show) or the empty pane (/messages). chats.css
|
|
10
|
+
decides with one media query: at least 700px wide both panes show; below
|
|
11
|
+
that the page is the list (/messages) or the thread (/messages/:id), as
|
|
12
|
+
with `:stacked`, which renders the list alone and the thread on its own
|
|
13
|
+
page (show.html.erb). Every request renders the same markup; only CSS
|
|
14
|
+
knows the width, so it works before JavaScript runs and without it.
|
|
15
|
+
|
|
16
|
+
A row is a real link to /messages/:id. On a wide screen it targets the
|
|
17
|
+
frame: the server answers with the pane alone (pane.html.erb) and the
|
|
18
|
+
frame's data-turbo-action="advance" moves the address bar and history
|
|
19
|
+
there. A reload, a deep link or back/forward renders the whole page.
|
|
20
|
+
The frame is `disabled` until chats--split enables it, and only while
|
|
21
|
+
the screen is wide: on a phone, and without JavaScript, the same link
|
|
22
|
+
opens the thread page. The pane keeps its draft, scroll and stream
|
|
23
|
+
through every refresh of the list (see _conversation_pane).
|
|
24
|
+
|
|
25
|
+
Rows come from Chats::Inbox and are either a Chats::Conversation or a
|
|
26
|
+
Chats::InboxGroup (every direct thread with a `inbox: :grouped` messager,
|
|
27
|
+
stacked into one row).
|
|
28
|
+
|
|
29
|
+
The chats--refresh-inbox wrapper heals a MISSED refresh broadcast: Action
|
|
30
|
+
Cable has no replay, so a broadcast sent while the socket was down (tab
|
|
31
|
+
backgrounded, network blip) would leave the inbox stale until the user
|
|
32
|
+
navigated. The controller re-runs the same page refresh when the stream
|
|
33
|
+
reconnects, when the page comes back after 10 seconds hidden, when the
|
|
34
|
+
network comes back and when the browser restores the page (see
|
|
35
|
+
chats/recovery.js), and reruns a refresh Turbo dropped because it
|
|
36
|
+
arrived mid-visit. It wraps the <turbo-cable-stream-source> that
|
|
37
|
+
`turbo_stream_from` renders: the stream's own confirmations are how it
|
|
38
|
+
sees a reconnect. One per page: don't wrap it in a reconciler of your
|
|
39
|
+
own as well. %>
|
|
40
|
+
|
|
41
|
+
<%# The OTHER inbox-staleness case, distinct from a missed broadcast: a Turbo
|
|
42
|
+
RESTORATION visit (browser back / Hotwire Native stack pop) serves the
|
|
43
|
+
inbox's cached snapshot with no GET. Open a chat from a profile, send a
|
|
44
|
+
message, tap back — the snapshot cached before the chat existed comes back
|
|
45
|
+
and the new conversation is missing until you navigate away and return.
|
|
46
|
+
The wrapper above can't help (it fires on reconnect and visibility, not
|
|
47
|
+
on a restore visit). `turbo-cache-control: no-cache` makes the inbox
|
|
48
|
+
uncacheable, so a restore visit re-fetches from the network and is always
|
|
49
|
+
fresh — on the web AND in Hotwire Native, whose back-pop issues a Turbo
|
|
50
|
+
`.restore` visit that honors this. Requires the host layout to
|
|
51
|
+
`yield :head` (the Rails convention); harmless no-op otherwise.
|
|
52
|
+
https://turbo.hotwired.dev/handbook/building#opting-out-of-caching %>
|
|
53
|
+
<%# The page's title, for a layout that shows `yield(:title)`: the inbox's,
|
|
54
|
+
or the open conversation's (chats_page_title, config.page_title). %>
|
|
55
|
+
<% content_for :title, chats_page_title(@conversation) %>
|
|
56
|
+
<% content_for :head do %>
|
|
57
|
+
<meta name="turbo-cache-control" content="no-cache">
|
|
58
|
+
<% end %>
|
|
5
59
|
<%= chats_styles %>
|
|
6
|
-
|
|
60
|
+
<div data-controller="chats--refresh-inbox">
|
|
61
|
+
<%= turbo_stream_from chats_current_messager, :chats_inbox %>
|
|
62
|
+
</div>
|
|
7
63
|
|
|
8
|
-
|
|
64
|
+
<% split = chats_two_panes? %>
|
|
65
|
+
<% inbox_empty = @rows.none? && params[:q].blank? %>
|
|
66
|
+
<% list = capture do %>
|
|
9
67
|
<header class="chats-inbox__header">
|
|
10
68
|
<h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
|
|
11
69
|
</header>
|
|
12
70
|
|
|
13
|
-
<% if
|
|
14
|
-
|
|
15
|
-
|
|
71
|
+
<% if @inbox.filtered? %>
|
|
72
|
+
<p class="chats-inbox__filter">
|
|
73
|
+
<%= t("chats.inbox.filtered_by", name: Chats.display_name_for(@inbox.with)) %>
|
|
74
|
+
<%= link_to t("chats.inbox.clear_filter"), conversations_path, class: "chats-inbox__filter-clear" %>
|
|
75
|
+
</p>
|
|
76
|
+
<% end %>
|
|
77
|
+
|
|
78
|
+
<% if Chats.config.search && !@inbox.filtered? %>
|
|
79
|
+
<%# No `action` (so a bare tag.form: form_with always writes one): a form
|
|
80
|
+
without one submits to the page's own URL (the HTML standard, and
|
|
81
|
+
Turbo too), whatever that is NOW — /messages,
|
|
82
|
+
/messages/ (the engine answers both), or /messages/:id after a row
|
|
83
|
+
opened a conversation in the pane and moved the address bar. So the
|
|
84
|
+
URL a search puts in the address bar is still this page to Turbo,
|
|
85
|
+
the next refresh morphs it instead of re-rendering it from scratch,
|
|
86
|
+
and on the two-pane page the open conversation stays open. %>
|
|
87
|
+
<%= tag.form method: :get,
|
|
16
88
|
class: "chats-search",
|
|
17
89
|
role: "search",
|
|
18
90
|
data: {
|
|
19
91
|
controller: "chats--debounced-submit",
|
|
20
92
|
action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
|
|
21
93
|
"chats--debounced-submit-delay-value": 250,
|
|
22
|
-
turbo_frame: "chats_inbox_results"
|
|
94
|
+
turbo_frame: "chats_inbox_results",
|
|
95
|
+
# The query goes in the URL (replaced, not pushed): a page
|
|
96
|
+
# refresh re-requests the URL, so a live update or a catch-up
|
|
97
|
+
# keeps the results and the field as they are.
|
|
98
|
+
turbo_action: "replace"
|
|
23
99
|
} do %>
|
|
24
100
|
<%= search_field_tag :q, params[:q],
|
|
25
101
|
placeholder: t("chats.inbox.search_placeholder"),
|
|
@@ -29,13 +105,26 @@
|
|
|
29
105
|
<% end %>
|
|
30
106
|
|
|
31
107
|
<%= turbo_frame_tag "chats_inbox_results", target: "_top" do %>
|
|
32
|
-
|
|
108
|
+
<%# Slot: anything the host wants above the first row — a support door,
|
|
109
|
+
an announcement, a filter bar. Renders only when the partial exists. %>
|
|
110
|
+
<%= chats_slot :inbox_top, viewer: chats_current_messager, inbox: @inbox %>
|
|
111
|
+
|
|
112
|
+
<% if @rows.any? %>
|
|
33
113
|
<ul class="chats-inbox__list">
|
|
34
|
-
<% @
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
114
|
+
<% @rows.each do |row| %>
|
|
115
|
+
<% if row.is_a?(Chats::InboxGroup) %>
|
|
116
|
+
<%= render "chats/conversations/group", group: row, viewer: chats_current_messager,
|
|
117
|
+
pane: split, current: @conversation,
|
|
118
|
+
filter: chats_inbox_filter %>
|
|
119
|
+
<% else %>
|
|
120
|
+
<%= render "chats/conversations/conversation_row",
|
|
121
|
+
conversation: row,
|
|
122
|
+
viewer: chats_current_messager,
|
|
123
|
+
unread_count: @unread_counts.fetch(row.id, 0),
|
|
124
|
+
pane: split,
|
|
125
|
+
current: @conversation,
|
|
126
|
+
filter: chats_inbox_filter %>
|
|
127
|
+
<% end %>
|
|
39
128
|
<% end %>
|
|
40
129
|
</ul>
|
|
41
130
|
<% elsif params[:q].present? %>
|
|
@@ -43,12 +132,50 @@
|
|
|
43
132
|
<p class="chats-empty__title"><%= t("chats.inbox.no_results_title") %></p>
|
|
44
133
|
<p class="chats-empty__hint"><%= t("chats.inbox.no_results_hint", query: params[:q]) %></p>
|
|
45
134
|
</div>
|
|
46
|
-
<%
|
|
135
|
+
<% elsif !split %>
|
|
136
|
+
<%# (Two panes: the empty pane says it, once — chats/conversations/_empty_pane.) %>
|
|
47
137
|
<div class="chats-empty">
|
|
48
138
|
<span class="chats-empty__icon" aria-hidden="true">💬</span>
|
|
49
139
|
<p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
|
|
50
140
|
<p class="chats-empty__hint"><%= t("chats.inbox.empty_hint") %></p>
|
|
141
|
+
<%= chats_slot :inbox_empty, viewer: chats_current_messager, inbox: @inbox %>
|
|
51
142
|
</div>
|
|
52
143
|
<% end %>
|
|
53
144
|
<% end %>
|
|
54
|
-
|
|
145
|
+
<% end %>
|
|
146
|
+
|
|
147
|
+
<%# data-chats-dock-hide: the messaging dock hides itself on this page. %>
|
|
148
|
+
<% if split %>
|
|
149
|
+
<%= tag.div class: "chats chats-inbox chats-split",
|
|
150
|
+
data: {
|
|
151
|
+
chats_dock_hide: true,
|
|
152
|
+
controller: "chats--split",
|
|
153
|
+
action: "turbo:before-prefetch->chats--split#prefetch " \
|
|
154
|
+
"turbo:frame-load->chats--split#opened " \
|
|
155
|
+
"chats:read->chats--split#threadRead " \
|
|
156
|
+
"turbo:before-morph-attribute->chats--split#keepEnabled"
|
|
157
|
+
} do %>
|
|
158
|
+
<nav class="chats-split__list" aria-label="<%= t("chats.inbox.title") %>" data-chats--split-target="list">
|
|
159
|
+
<%= list %>
|
|
160
|
+
</nav>
|
|
161
|
+
|
|
162
|
+
<%= turbo_frame_tag Chats::ConversationsController::PANE_FRAME,
|
|
163
|
+
class: "chats-split__conversation",
|
|
164
|
+
target: "_top",
|
|
165
|
+
disabled: true,
|
|
166
|
+
data: { turbo_action: "advance", "chats--split-target": "frame" } do %>
|
|
167
|
+
<% if @conversation %>
|
|
168
|
+
<%= render "chats/conversations/conversation_pane",
|
|
169
|
+
conversation: @conversation, participant: @participant,
|
|
170
|
+
messages: @messages, more: @more_messages, first_unread_id: @first_unread_id,
|
|
171
|
+
counterpart: chats_counterpart, read_pending: @read_pending %>
|
|
172
|
+
<% else %>
|
|
173
|
+
<%= render "chats/conversations/empty_pane", inbox_empty: inbox_empty, inbox: @inbox %>
|
|
174
|
+
<% end %>
|
|
175
|
+
<% end %>
|
|
176
|
+
<% end %>
|
|
177
|
+
<% else %>
|
|
178
|
+
<div class="chats chats-inbox" data-chats-dock-hide>
|
|
179
|
+
<%= list %>
|
|
180
|
+
</div>
|
|
181
|
+
<% end %>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# The answer to a row click on the two-pane messaging page: the request
|
|
2
|
+
comes from the page's `chats_conversation` frame (Turbo-Frame header),
|
|
3
|
+
so the answer is that frame with the conversation pane in it and
|
|
4
|
+
nothing else — no list, turbo-rails' minimal frame layout
|
|
5
|
+
(ConversationsController#show). The pane carries its title
|
|
6
|
+
(data-title), which chats--split gives the tab.
|
|
7
|
+
Turbo swaps it into the frame and, the frame being
|
|
8
|
+
data-turbo-action="advance", moves the address bar to /messages/:id. %>
|
|
9
|
+
<%= turbo_frame_tag Chats::ConversationsController::PANE_FRAME do %>
|
|
10
|
+
<%= render "chats/conversations/conversation_pane",
|
|
11
|
+
conversation: @conversation, participant: @participant,
|
|
12
|
+
messages: @messages, more: @more_messages, first_unread_id: @first_unread_id,
|
|
13
|
+
counterpart: chats_counterpart, read_pending: @read_pending %>
|
|
14
|
+
<% end %>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
<%# Stale-thread catch-up payload (ConversationsController#refresh):
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
<%# Stale-thread catch-up payload (ConversationsController#refresh): what the
|
|
2
|
+
stream would have delivered while the client wasn't listening. Appends
|
|
3
|
+
are idempotent by dom_id, so a message that raced in through the live
|
|
4
|
+
stream during the fetch can't duplicate. %>
|
|
5
5
|
<%= turbo_stream.append dom_id(@conversation, :messages) do %>
|
|
6
6
|
<%= render partial: "chats/messages/message", collection: @new_messages, as: :message %>
|
|
7
7
|
<% end if @new_messages.any? %>
|
|
@@ -11,3 +11,23 @@
|
|
|
11
11
|
partial: "chats/messages/message",
|
|
12
12
|
locals: { message: message } %>
|
|
13
13
|
<% end %>
|
|
14
|
+
|
|
15
|
+
<%# Who has read what: a read the stream missed would leave "Sent" where it
|
|
16
|
+
should say "Seen". %>
|
|
17
|
+
<%= turbo_stream.replace dom_id(@conversation, :read_state),
|
|
18
|
+
partial: "chats/conversations/read_state",
|
|
19
|
+
locals: { conversation: @conversation } %>
|
|
20
|
+
|
|
21
|
+
<%# `deletion: :hard` only: every message from the client's oldest bubble on
|
|
22
|
+
that still exists (the chats_prune action, registered by the thread
|
|
23
|
+
controller, removes the rest). %>
|
|
24
|
+
<%# Where the client stands now (ConversationsController#caught_up_cursors):
|
|
25
|
+
its cursors move only when an answer like this one arrives. %>
|
|
26
|
+
<%= @caught_up %>
|
|
27
|
+
|
|
28
|
+
<% if @present %>
|
|
29
|
+
<%= turbo_stream_action_tag :chats_prune,
|
|
30
|
+
target: dom_id(@conversation, :history),
|
|
31
|
+
"data-ids": @present[:ids].join(" "),
|
|
32
|
+
"data-until": @present[:until] %>
|
|
33
|
+
<% end %>
|
|
@@ -1,137 +1,21 @@
|
|
|
1
|
-
<%# The thread
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
<%# The thread page under `config.inbox_layout = :stacked`: its header
|
|
2
|
+
(chats/conversations/_thread_header) over the shared thread body
|
|
3
|
+
(chats/conversations/_thread, which a floating dock window and the
|
|
4
|
+
two-pane page's conversation pane render too — see that partial for how
|
|
5
|
+
the live thread works). Under the default `:split`, /messages/:id is
|
|
6
|
+
the two-pane page instead (index.html.erb). %>
|
|
7
|
+
<% content_for :title, chats_page_title(@conversation) %>
|
|
7
8
|
<%= chats_styles %>
|
|
8
9
|
|
|
9
|
-
<%=
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"chats--thread-thread-url-value": conversation_path(@conversation),
|
|
21
|
-
"chats--thread-copied-label-value": t("chats.message.copied"),
|
|
22
|
-
action: "pointerdown->chats--thread#pressStart pointermove->chats--thread#pressMove " \
|
|
23
|
-
"pointerup->chats--thread#pressEnd pointercancel->chats--thread#pressCancel " \
|
|
24
|
-
"contextmenu->chats--thread#contextMenu"
|
|
25
|
-
} do %>
|
|
26
|
-
|
|
27
|
-
<%= turbo_stream_from @conversation %>
|
|
28
|
-
|
|
29
|
-
<header class="chats-thread__header">
|
|
30
|
-
<%= link_to root_path, class: "chats-thread__back", "aria-label": t("chats.thread.back") do %>
|
|
31
|
-
<span aria-hidden="true">‹</span>
|
|
32
|
-
<% end %>
|
|
33
|
-
|
|
34
|
-
<%= chats_conversation_avatar(@conversation, chats_current_messager) %>
|
|
35
|
-
|
|
36
|
-
<div class="chats-thread__identity">
|
|
37
|
-
<h1 class="chats-thread__title"><%= @conversation.title_for(chats_current_messager) %></h1>
|
|
38
|
-
<% if @conversation.subject_label %>
|
|
39
|
-
<p class="chats-thread__subject"><%= @conversation.subject_label %></p>
|
|
40
|
-
<% end %>
|
|
41
|
-
</div>
|
|
42
|
-
|
|
43
|
-
<details class="chats-menu">
|
|
44
|
-
<summary class="chats-menu__trigger" aria-label="<%= t("chats.thread.options") %>">⋯</summary>
|
|
45
|
-
<div class="chats-menu__panel">
|
|
46
|
-
<% if @participant&.muted? %>
|
|
47
|
-
<%= button_to t("chats.thread.unmute"), unmute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
|
|
48
|
-
<% else %>
|
|
49
|
-
<%= button_to t("chats.thread.mute"), mute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
|
|
50
|
-
<% end %>
|
|
51
|
-
<% if @conversation.group? %>
|
|
52
|
-
<%= button_to t("chats.thread.leave"), leave_conversation_path(@conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
|
|
53
|
-
<% end %>
|
|
54
|
-
</div>
|
|
55
|
-
</details>
|
|
56
|
-
</header>
|
|
57
|
-
|
|
58
|
-
<div class="chats-thread__scroll" data-chats--thread-target="scroller">
|
|
59
|
-
<% if @more_messages && @messages.any? %>
|
|
60
|
-
<%# Lazy frame chain for infinite scroll-up: when this frame becomes
|
|
61
|
-
visible, Turbo fetches the previous keyset page (?before=oldest id),
|
|
62
|
-
whose response contains a frame with this SAME id — older messages
|
|
63
|
-
plus, recursively, the next frame. See ConversationsController#show. %>
|
|
64
|
-
<%= turbo_frame_tag "chats_page_#{@messages.first.id}",
|
|
65
|
-
src: conversation_path(@conversation, before: @messages.first.id),
|
|
66
|
-
loading: :lazy do %>
|
|
67
|
-
<div class="chats-loader"><%= t("chats.thread.loading") %></div>
|
|
68
|
-
<% end %>
|
|
69
|
-
<% end %>
|
|
70
|
-
|
|
71
|
-
<div id="<%= dom_id(@conversation, :messages) %>" class="chats-thread__messages">
|
|
72
|
-
<%# Explicit loop (not a collection render) so the «new messages»
|
|
73
|
-
divider can sit BEFORE the first unread bubble (WhatsApp's "the
|
|
74
|
-
catch-up starts here" line). Server-rendered on the initial page
|
|
75
|
-
only; it scrolls away naturally as the thread lives on. %>
|
|
76
|
-
<% @messages.each do |message| %>
|
|
77
|
-
<% if message.id == @first_unread_id %>
|
|
78
|
-
<div class="chats-thread__unread-line" role="separator">
|
|
79
|
-
<span><%= t("chats.thread.new_messages") %></span>
|
|
80
|
-
</div>
|
|
81
|
-
<% end %>
|
|
82
|
-
<%= render "chats/messages/message", message: message %>
|
|
83
|
-
<% end %>
|
|
84
|
-
</div>
|
|
85
|
-
|
|
86
|
-
<%= render "chats/conversations/read_state", conversation: @conversation %>
|
|
87
|
-
|
|
88
|
-
<div id="<%= dom_id(@conversation, :typing) %>"
|
|
89
|
-
class="chats-typing"
|
|
90
|
-
data-chats--thread-target="typing"
|
|
91
|
-
hidden></div>
|
|
92
|
-
</div>
|
|
93
|
-
|
|
94
|
-
<%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
|
|
95
|
-
pressed bubble's clone morphs to the center, the reactions pill sits
|
|
96
|
-
above it and the contextual menu below. Slots are filled by cloning
|
|
97
|
-
the bubble's <template data-chats-message-menu>. %>
|
|
98
|
-
<div class="chats-popup"
|
|
99
|
-
role="dialog"
|
|
100
|
-
aria-modal="true"
|
|
101
|
-
data-chats--thread-target="popup"
|
|
102
|
-
hidden>
|
|
103
|
-
<div class="chats-popup__backdrop" data-action="click->chats--thread#closePopup"></div>
|
|
104
|
-
<div class="chats-popup__stack">
|
|
105
|
-
<div class="chats-popup__slot chats-popup__slot--reactions"
|
|
106
|
-
data-chats--thread-target="popupReactions"
|
|
107
|
-
data-action="click->chats--thread#popupMenuClicked"></div>
|
|
108
|
-
<div class="chats-popup__slot chats-popup__slot--bubble" data-chats--thread-target="popupBubble"></div>
|
|
109
|
-
<div class="chats-popup__slot chats-popup__slot--menu"
|
|
110
|
-
data-chats--thread-target="popupMenu"
|
|
111
|
-
data-action="click->chats--thread#popupMenuClicked"></div>
|
|
112
|
-
</div>
|
|
113
|
-
</div>
|
|
114
|
-
|
|
115
|
-
<div class="chats-attachment-preview"
|
|
116
|
-
role="dialog"
|
|
117
|
-
aria-modal="true"
|
|
118
|
-
tabindex="-1"
|
|
119
|
-
data-chats--thread-target="attachmentDialog"
|
|
120
|
-
data-action="keydown.esc->chats--thread#closeAttachment"
|
|
121
|
-
hidden>
|
|
122
|
-
<div class="chats-attachment-preview__surface"
|
|
123
|
-
data-action="click->chats--thread#closeAttachmentFromBackdrop">
|
|
124
|
-
<button type="button"
|
|
125
|
-
class="chats-attachment-preview__close"
|
|
126
|
-
aria-label="<%= t("chats.message.close_attachment") %>"
|
|
127
|
-
data-action="chats--thread#closeAttachment">×</button>
|
|
128
|
-
<figure class="chats-attachment-preview__figure">
|
|
129
|
-
<img class="chats-attachment-preview__image"
|
|
130
|
-
alt=""
|
|
131
|
-
data-chats--thread-target="attachmentImage">
|
|
132
|
-
</figure>
|
|
133
|
-
</div>
|
|
134
|
-
</div>
|
|
135
|
-
|
|
136
|
-
<%= render "chats/messages/composer", conversation: @conversation %>
|
|
10
|
+
<%= render layout: "chats/conversations/thread",
|
|
11
|
+
locals: {
|
|
12
|
+
conversation: @conversation,
|
|
13
|
+
messages: @messages,
|
|
14
|
+
more: @more_messages,
|
|
15
|
+
first_unread_id: @first_unread_id,
|
|
16
|
+
counterpart: chats_counterpart,
|
|
17
|
+
read_pending: @read_pending
|
|
18
|
+
} do %>
|
|
19
|
+
<%= render "chats/conversations/thread_header",
|
|
20
|
+
conversation: @conversation, participant: @participant, counterpart: chats_counterpart %>
|
|
137
21
|
<% end %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%# A floating dock window's content (GET /:id?window=1 from inside the
|
|
2
|
+
window's turbo frame — see chats/dock/_window for the frame and
|
|
3
|
+
ConversationsController#show): the SAME thread body the page renders,
|
|
4
|
+
rooted at .chats-thread--window, under the window's compact header.
|
|
5
|
+
|
|
6
|
+
The header's chats--dock actions reach the dock's controller because the
|
|
7
|
+
window lives inside the dock element. The title is a button: it
|
|
8
|
+
minimizes and expands the window (LinkedIn's header click), and says
|
|
9
|
+
which way with aria-expanded. %>
|
|
10
|
+
<% title = @conversation.title_for(chats_current_messager) %>
|
|
11
|
+
<%= turbo_frame_tag dom_id(@conversation, :window) do %>
|
|
12
|
+
<section class="chats-window__card" role="region" aria-label="<%= t("chats.dock.window_label", name: title) %>">
|
|
13
|
+
<%= render layout: "chats/conversations/thread",
|
|
14
|
+
locals: {
|
|
15
|
+
conversation: @conversation,
|
|
16
|
+
messages: @messages,
|
|
17
|
+
more: @more_messages,
|
|
18
|
+
first_unread_id: @first_unread_id,
|
|
19
|
+
counterpart: chats_counterpart,
|
|
20
|
+
window: true
|
|
21
|
+
} do %>
|
|
22
|
+
<%= render "chats/dock/window_header", conversation: @conversation, title: title,
|
|
23
|
+
counterpart: chats_counterpart %>
|
|
24
|
+
<% end %>
|
|
25
|
+
</section>
|
|
26
|
+
<% end %>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%# The dock's unread badge: the same number as the nav badge
|
|
2
|
+
(`unread_chats_count`), replaced on the viewer's dock stream with every
|
|
3
|
+
other [data-chats-unread-badge] inside the dock. No id — a page may hold
|
|
4
|
+
the nav badge too, and ids must stay unique. Not a live region: it would
|
|
5
|
+
chatter on every read; new messages are announced by the dock instead.
|
|
6
|
+
Hidden at zero, number kept. %>
|
|
7
|
+
<%= tag.span class: "chats-badge chats-dock__badge",
|
|
8
|
+
hidden: !count.positive?,
|
|
9
|
+
data: { chats_unread_badge: true, "chats--dock-target": "badge", count: count } do %>
|
|
10
|
+
<span aria-hidden="true"><%= count > 99 ? "99+" : count %></span>
|
|
11
|
+
<span class="chats-visually-hidden"><%= count %> <%= t("chats.dock.unread_suffix") %></span>
|
|
12
|
+
<% end %>
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
"turbo:frame-load@document->chats--dock#pageFrameLoaded"
|
|
54
|
+
} do %>
|
|
55
|
+
<%= turbo_stream_from viewer, :chats_dock %>
|
|
56
|
+
<%= render "chats/dock/signal" %>
|
|
57
|
+
|
|
58
|
+
<div id="chats_dock_windows" class="chats-dock__windows" data-chats--dock-target="windows"></div>
|
|
59
|
+
|
|
60
|
+
<%# What a window shows before its thread arrives, and if it can't. %>
|
|
61
|
+
<template data-chats--dock-target="loadingShell"><%= render "chats/dock/window_shell", state: :loading %></template>
|
|
62
|
+
<template data-chats--dock-target="failedShell"><%= render "chats/dock/window_shell", state: :failed %></template>
|
|
63
|
+
|
|
64
|
+
<details class="chats-dock__panel"
|
|
65
|
+
data-chats--dock-target="panel"
|
|
66
|
+
data-action="toggle->chats--dock#panelToggled">
|
|
67
|
+
<%# The summary reads "Messages, 3 unread" from its own text; on a phone
|
|
68
|
+
the closed one is the round launcher (chats.css). %>
|
|
69
|
+
<summary class="chats-dock__bar chats-dock__toggle">
|
|
70
|
+
<%= chats_messager_avatar(viewer) %>
|
|
71
|
+
<svg class="chats-dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
72
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
|
73
|
+
<path d="M12 3a9 9 0 0 0-7.8 13.5L3 21l4.6-1.2A9 9 0 1 0 12 3Z" />
|
|
74
|
+
</svg>
|
|
75
|
+
<span class="chats-dock__title"><%= t("chats.dock.title") %></span>
|
|
76
|
+
<%= render "chats/dock/badge", count: viewer.unread_chats_count %>
|
|
77
|
+
<span class="chats-dock__chevron" aria-hidden="true">
|
|
78
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
79
|
+
stroke-linejoin="round" focusable="false"><path d="m6 15 6-6 6 6" /></svg>
|
|
80
|
+
</span>
|
|
81
|
+
<span class="chats-dock__close-glyph" aria-hidden="true">
|
|
82
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
83
|
+
focusable="false"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
|
84
|
+
</span>
|
|
85
|
+
</summary>
|
|
86
|
+
|
|
87
|
+
<div class="chats-dock__body">
|
|
88
|
+
<% if Chats.config.search %>
|
|
89
|
+
<%# The inbox's own search: with JavaScript the results land in the
|
|
90
|
+
dock's frame (the inbox answers that frame with the dock's list);
|
|
91
|
+
without it, on the full inbox. %>
|
|
92
|
+
<%= form_with url: chats_routes.conversations_path,
|
|
93
|
+
method: :get,
|
|
94
|
+
class: "chats-search chats-dock__search",
|
|
95
|
+
# `html:`: form_with drops attributes it doesn't know (role among them).
|
|
96
|
+
html: { role: "search" },
|
|
97
|
+
data: {
|
|
98
|
+
controller: "chats--debounced-submit",
|
|
99
|
+
action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
|
|
100
|
+
"chats--debounced-submit-delay-value": 250,
|
|
101
|
+
turbo_frame: "chats_dock_list"
|
|
102
|
+
} do %>
|
|
103
|
+
<%= hidden_field_tag :recent, recent, id: nil %>
|
|
104
|
+
<%= search_field_tag :q, nil,
|
|
105
|
+
id: "chats_dock_q",
|
|
106
|
+
placeholder: t("chats.inbox.search_placeholder"),
|
|
107
|
+
"aria-label": t("chats.inbox.search_placeholder"),
|
|
108
|
+
class: "chats-search__input",
|
|
109
|
+
autocomplete: "off" %>
|
|
110
|
+
<% end %>
|
|
111
|
+
<% end %>
|
|
112
|
+
|
|
113
|
+
<%# Lazy: nothing is fetched until the panel first opens; until then,
|
|
114
|
+
four still rows. The controller reloads it when the signal says,
|
|
115
|
+
and when its stream may have missed a signal. `refresh: :morph`:
|
|
116
|
+
a reload morphs the rows in place, so the list keeps its scroll
|
|
117
|
+
position, and it reloads its own src, so a search stays. %>
|
|
118
|
+
<%= turbo_frame_tag "chats_dock_list",
|
|
119
|
+
src: chats_routes.dock_conversations_path(recent: recent),
|
|
120
|
+
loading: :lazy,
|
|
121
|
+
refresh: :morph,
|
|
122
|
+
target: "_top",
|
|
123
|
+
class: "chats-dock__results",
|
|
124
|
+
# A click here opens a window, never the page: hovering a row
|
|
125
|
+
# must not prefetch it.
|
|
126
|
+
data: { "chats--dock-target": "list", turbo_prefetch: false } do %>
|
|
127
|
+
<div aria-busy="true" aria-label="<%= t("chats.thread.loading") %>">
|
|
128
|
+
<% 4.times do %><div class="chats-dock__skeleton"><span></span></div><% end %>
|
|
129
|
+
</div>
|
|
130
|
+
<% end %>
|
|
131
|
+
|
|
132
|
+
<%= link_to t("chats.dock.see_all"), chats_routes.conversations_path, class: "chats-dock__see-all" %>
|
|
133
|
+
<%= chats_slot :dock_panel_footer, viewer: viewer %>
|
|
134
|
+
</div>
|
|
135
|
+
</details>
|
|
136
|
+
|
|
137
|
+
<%# "New message from …": polite, one at a time. The badge is not live. %>
|
|
138
|
+
<div class="chats-visually-hidden" aria-live="polite" aria-atomic="true" data-chats--dock-target="announcer"></div>
|
|
139
|
+
<% 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 %>
|