chats 0.5.0 → 0.6.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 +349 -0
- data/README.md +91 -8
- data/app/assets/stylesheets/chats.css +114 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +305 -63
- data/app/helpers/chats/engine_helper.rb +27 -4
- data/app/javascript/chats/dock_controller.js +54 -4
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/split_controller.js +97 -0
- data/app/javascript/chats/thread_controller.js +245 -54
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +20 -4
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_thread.html.erb +29 -54
- 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/index.html.erb +91 -15
- data/app/views/chats/conversations/pane.html.erb +12 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +10 -65
- data/app/views/chats/dock/_dock.html.erb +14 -5
- data/app/views/chats/messages/_message.html.erb +8 -6
- data/chats-0.3.2.gem +0 -0
- data/chats-0.4.0.gem +0 -0
- data/chats-0.4.1.gem +0 -0
- data/chats-0.4.2.gem +0 -0
- data/chats-0.5.0.gem +0 -0
- data/chats-0.5.1.gem +0 -0
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/config/routes.rb +3 -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/lib/chats/broadcasts.rb +102 -7
- data/lib/chats/configuration.rb +23 -0
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/conversation.rb +38 -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 +9 -0
- metadata +21 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%# The open conversation on the two-pane messaging page (`config.inbox_layout
|
|
2
|
+
= :split`), inside the page's `chats_conversation` frame: the header
|
|
3
|
+
(chats/conversations/_thread_header) over the shared thread body
|
|
4
|
+
(chats/conversations/_thread, the same one the thread page and a dock
|
|
5
|
+
window render).
|
|
6
|
+
|
|
7
|
+
The thread is wrapped in a data-turbo-permanent element keyed by the
|
|
8
|
+
conversation, the way the dock keys its own element: the inbox's live
|
|
9
|
+
updates are Turbo 8 page refreshes that MORPH this whole page, and a
|
|
10
|
+
morph leaves a permanent element alone, so the draft, the scroll
|
|
11
|
+
position, the read state and the thread's stream subscription all go
|
|
12
|
+
on untouched while the list beside it re-renders. Opening another
|
|
13
|
+
conversation renders another id, which replaces it. The id also names
|
|
14
|
+
the viewer: a visit that lands on another account's page (signing out
|
|
15
|
+
and in, an account switcher) never inherits someone else's thread.
|
|
16
|
+
|
|
17
|
+
The header stays outside it, so a refresh still brings it up to date
|
|
18
|
+
(a mute, a renamed group).
|
|
19
|
+
|
|
20
|
+
Locals: conversation, participant, messages, more, first_unread_id,
|
|
21
|
+
counterpart, read_pending (as in chats/conversations/_thread). %>
|
|
22
|
+
<% title_id = dom_id(conversation, :title) %>
|
|
23
|
+
<%= tag.section class: "chats-split__pane", "aria-labelledby": title_id,
|
|
24
|
+
data: { conversation_id: conversation.id } do %>
|
|
25
|
+
<%= render "chats/conversations/thread_header",
|
|
26
|
+
conversation: conversation, participant: participant, counterpart: counterpart,
|
|
27
|
+
heading: :h2, title_id: title_id, back_path: conversations_path(**chats_inbox_filter) %>
|
|
28
|
+
|
|
29
|
+
<%= tag.div id: "chats_conversation_pane_#{conversation.id}_#{Chats.messager_key(chats_current_messager)}",
|
|
30
|
+
class: "chats-split__thread", data: { turbo_permanent: true } do %>
|
|
31
|
+
<%= render layout: "chats/conversations/thread",
|
|
32
|
+
locals: {
|
|
33
|
+
conversation: conversation,
|
|
34
|
+
messages: messages,
|
|
35
|
+
more: more,
|
|
36
|
+
first_unread_id: first_unread_id,
|
|
37
|
+
counterpart: counterpart,
|
|
38
|
+
pane: true,
|
|
39
|
+
read_pending: read_pending
|
|
40
|
+
} do %><% end %>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% end %>
|
|
@@ -5,10 +5,26 @@
|
|
|
5
5
|
`dock: true` (the messaging dock's recent list) keeps the link a link to
|
|
6
6
|
the thread and marks it as a dock opener: a plain click opens the
|
|
7
7
|
conversation's floating window instead. A muted conversation carries
|
|
8
|
-
the bell-slash.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
the bell-slash.
|
|
9
|
+
|
|
10
|
+
`pane: true` (the two-pane messaging page) points the link at the
|
|
11
|
+
page's conversation frame, which opens it beside the list on a wide
|
|
12
|
+
screen; on a phone, and without JavaScript, the frame is disabled and
|
|
13
|
+
the link opens the thread page. `current:` is the conversation the page
|
|
14
|
+
has open: its row says so (aria-current="page", .chats-row--current).
|
|
15
|
+
`filter:` is the list's search or stack (`q`, `with`), kept in the
|
|
16
|
+
link: the list's refreshes re-request the page's URL, and must find the
|
|
17
|
+
same list there. %>
|
|
18
|
+
<% current = local_assigns[:current] == conversation %>
|
|
19
|
+
<% filter = local_assigns[:pane] ? local_assigns.fetch(:filter, {}) : {} %>
|
|
20
|
+
<li class="<%= class_names("chats-row", "chats-row--unread": unread_count.positive?, "chats-row--current": current) %>">
|
|
21
|
+
<%= link_to conversation_path(conversation, **filter), class: "chats-row__link",
|
|
22
|
+
"aria-current": ("page" if current),
|
|
23
|
+
data: (if local_assigns[:dock]
|
|
24
|
+
chats_dock_opener(conversation)
|
|
25
|
+
elsif local_assigns[:pane]
|
|
26
|
+
{ turbo_frame: Chats::ConversationsController::PANE_FRAME }
|
|
27
|
+
end) do %>
|
|
12
28
|
<%= chats_conversation_avatar(conversation, viewer) %>
|
|
13
29
|
|
|
14
30
|
<span class="chats-row__body">
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<%# The conversation pane of the two-pane messaging page with no
|
|
2
|
+
conversation open (/messages, `config.inbox_layout = :split`).
|
|
3
|
+
|
|
4
|
+
With conversations to choose from, it asks for one. With none at all,
|
|
5
|
+
the inbox's own empty state lives here instead, the host's
|
|
6
|
+
inbox_empty slot included: rendered once on the page, never once in the
|
|
7
|
+
list and again beside it. On narrow screens, where the list is the
|
|
8
|
+
page, chats.css shows this pane under the list only in that case.
|
|
9
|
+
|
|
10
|
+
Locals: inbox_empty (no rows, no search), inbox (the Chats::Inbox). %>
|
|
11
|
+
<% if inbox_empty %>
|
|
12
|
+
<div class="chats-split__empty chats-split__empty--inbox chats-empty">
|
|
13
|
+
<span class="chats-empty__icon" aria-hidden="true">💬</span>
|
|
14
|
+
<p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
|
|
15
|
+
<p class="chats-empty__hint"><%= t("chats.inbox.empty_hint") %></p>
|
|
16
|
+
<%= chats_slot :inbox_empty, viewer: chats_current_messager, inbox: inbox %>
|
|
17
|
+
</div>
|
|
18
|
+
<% else %>
|
|
19
|
+
<div class="chats-split__empty chats-empty">
|
|
20
|
+
<span class="chats-empty__icon" aria-hidden="true">💬</span>
|
|
21
|
+
<p class="chats-empty__title"><%= t("chats.inbox.select_hint") %></p>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
@@ -8,11 +8,27 @@
|
|
|
8
8
|
either the host's `group_path:` or chats' own filtered inbox.
|
|
9
9
|
|
|
10
10
|
In the messaging dock (`dock: true`) a stack of one opens its thread's
|
|
11
|
-
floating window; a deeper stack still goes to the stack list.
|
|
12
|
-
|
|
11
|
+
floating window; a deeper stack still goes to the stack list. On the
|
|
12
|
+
two-pane page (`pane: true`) a stack of one opens its thread in the
|
|
13
|
+
conversation pane, and is the current row while it is open there
|
|
14
|
+
(`current:`, `filter:`, as in _conversation_row); a deeper stack still
|
|
15
|
+
goes to the stack list. %>
|
|
16
|
+
<% single = group.single? %>
|
|
17
|
+
<% current = single && local_assigns[:current].present? && local_assigns[:current] == group.conversation %>
|
|
18
|
+
<li class="<%= class_names("chats-row chats-row--group", "chats-row--unread": group.unread?, "chats-row--current": current) %>"
|
|
13
19
|
id="<%= group.dom_id %>">
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
<% href = if local_assigns[:pane] && single
|
|
21
|
+
conversation_path(group.conversation, **local_assigns.fetch(:filter, {}))
|
|
22
|
+
else
|
|
23
|
+
chats_group_path(group)
|
|
24
|
+
end %>
|
|
25
|
+
<%= link_to href, class: "chats-row__link",
|
|
26
|
+
"aria-current": ("page" if current),
|
|
27
|
+
data: (if local_assigns[:dock] && single
|
|
28
|
+
chats_dock_opener(group.conversation)
|
|
29
|
+
elsif local_assigns[:pane] && single
|
|
30
|
+
{ turbo_frame: Chats::ConversationsController::PANE_FRAME }
|
|
31
|
+
end) do %>
|
|
16
32
|
<%= chats_messager_avatar(group.messager) %>
|
|
17
33
|
|
|
18
34
|
<span class="chats-row__body">
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<%# The history inside a thread's scroller: the latest page of messages, the
|
|
2
|
+
lazy frame chain above it, the read state and the typing line. The thread
|
|
3
|
+
(chats/conversations/_thread) renders it once; the stale-thread catch-up
|
|
4
|
+
re-renders it in place when the thread fell more than a page behind, and
|
|
5
|
+
leaves everything else (the header, the composer and its draft) alone.
|
|
6
|
+
|
|
7
|
+
Locals: conversation, messages (oldest first), more, first_unread_id,
|
|
8
|
+
counterpart, window — as in chats/conversations/_thread. %>
|
|
9
|
+
<% viewer = chats_current_messager %>
|
|
10
|
+
<% if window && messages.empty? %>
|
|
11
|
+
<%# A window opened from a Message button, before anybody wrote: who
|
|
12
|
+
you're about to talk to. Gone with the first bubble (chats.css). %>
|
|
13
|
+
<div class="chats-window__hello">
|
|
14
|
+
<% if counterpart %>
|
|
15
|
+
<%= chats_messager_avatar(counterpart) %>
|
|
16
|
+
<strong><%= Chats.display_name_for(counterpart) %></strong>
|
|
17
|
+
<% end %>
|
|
18
|
+
<%= t("chats.dock.hello", name: conversation.title_for(viewer)) %>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% if more && messages.any? %>
|
|
22
|
+
<%# Lazy frame chain for infinite scroll-up: when this frame becomes
|
|
23
|
+
visible, Turbo fetches the previous keyset page (?before=oldest id),
|
|
24
|
+
whose response contains a frame with this SAME id — older messages
|
|
25
|
+
plus, recursively, the next frame. See ConversationsController#show. %>
|
|
26
|
+
<%= turbo_frame_tag "chats_page_#{messages.first.id}",
|
|
27
|
+
src: conversation_path(conversation, before: messages.first.id),
|
|
28
|
+
loading: :lazy do %>
|
|
29
|
+
<div class="chats-loader"><%= t("chats.thread.loading") %></div>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% end %>
|
|
32
|
+
|
|
33
|
+
<div id="<%= dom_id(conversation, :messages) %>" class="chats-thread__messages">
|
|
34
|
+
<%# Explicit loop (not a collection render) so the «new messages»
|
|
35
|
+
divider can sit BEFORE the first unread bubble (WhatsApp's "the
|
|
36
|
+
catch-up starts here" line). Rendered with the history only (the
|
|
37
|
+
thread's first render, or a catch-up that re-renders it); it scrolls
|
|
38
|
+
away naturally as the thread lives on. %>
|
|
39
|
+
<% messages.each do |message| %>
|
|
40
|
+
<% if message.id == first_unread_id %>
|
|
41
|
+
<div class="chats-thread__unread-line" role="separator">
|
|
42
|
+
<span><%= t("chats.thread.new_messages") %></span>
|
|
43
|
+
</div>
|
|
44
|
+
<% end %>
|
|
45
|
+
<%= render "chats/messages/message", message: message %>
|
|
46
|
+
<% end %>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<%= render "chats/conversations/read_state", conversation: conversation %>
|
|
50
|
+
|
|
51
|
+
<div id="<%= dom_id(conversation, :typing) %>"
|
|
52
|
+
class="chats-typing"
|
|
53
|
+
data-chats--thread-target="typing"
|
|
54
|
+
hidden></div>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
<%# The thread body: everything live about one open conversation, shared by
|
|
2
|
-
the thread PAGE (show.html.erb)
|
|
3
|
-
(
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
full page)
|
|
2
|
+
the thread PAGE (show.html.erb), a floating dock WINDOW (window.html.erb)
|
|
3
|
+
and the two-pane page's conversation PANE (_conversation_pane.html.erb).
|
|
4
|
+
Rendered as a partial LAYOUT — `yield` is the header, which is the one
|
|
5
|
+
thing they differ in: the page keeps its full header (back link, ⋯ menu),
|
|
6
|
+
the window its compact one (minimize, close, open full page); the pane
|
|
7
|
+
renders its header above the thread, outside it, and yields nothing.
|
|
7
8
|
|
|
8
9
|
One conversation stream powers everything here: message
|
|
9
10
|
append/replace/remove, the read-state payload, and typing pings. Bubbles
|
|
@@ -28,10 +29,19 @@
|
|
|
28
29
|
server did NOT read this render — the controller
|
|
29
30
|
posts #read once the window is on screen and the
|
|
30
31
|
viewer is engaged with it (`readPending`).
|
|
32
|
+
pane true in the two-pane page's conversation pane: rooted
|
|
33
|
+
at .chats-thread--pane, inside the page's .chats shell.
|
|
34
|
+
read_pending the server did not read this render, so the
|
|
35
|
+
controller posts #read once the thread is on screen
|
|
36
|
+
(always true in a window; see
|
|
37
|
+
ConversationsController#show for when a page or a
|
|
38
|
+
pane is rendered unread).
|
|
31
39
|
|
|
32
40
|
The page's root carries data-chats-dock-hide: the messaging dock hides
|
|
33
41
|
itself on any page that has it. %>
|
|
34
42
|
<% window = local_assigns.fetch(:window, false) %>
|
|
43
|
+
<% pane = local_assigns.fetch(:pane, false) %>
|
|
44
|
+
<% read_pending = window || local_assigns.fetch(:read_pending, false) %>
|
|
35
45
|
<% viewer = chats_current_messager %>
|
|
36
46
|
<%# A direct thread nobody may write in any more says so up front, in the
|
|
37
47
|
composer's place — the host's policy turned false, or the other account
|
|
@@ -39,12 +49,16 @@
|
|
|
39
49
|
the live lock swap still finds it. %>
|
|
40
50
|
<% refused = !conversation.locked? && conversation.direct? &&
|
|
41
51
|
(counterpart.nil? || !Chats.can_message?(viewer, counterpart)) %>
|
|
42
|
-
|
|
52
|
+
<% root_class = if window then "chats-thread chats-thread--window"
|
|
53
|
+
elsif pane then "chats-thread chats-thread--pane"
|
|
54
|
+
else "chats chats-thread"
|
|
55
|
+
end %>
|
|
56
|
+
<%= tag.div class: root_class, data: {
|
|
43
57
|
controller: "chats--thread",
|
|
44
|
-
"chats-dock-hide": (true unless window),
|
|
58
|
+
"chats-dock-hide": (true unless window || pane), # the pane's page carries it
|
|
45
59
|
"chats--thread-me-value": Chats.messager_key(viewer),
|
|
46
60
|
"chats--thread-read-url-value": read_conversation_path(conversation),
|
|
47
|
-
"chats--thread-read-pending-value":
|
|
61
|
+
"chats--thread-read-pending-value": read_pending,
|
|
48
62
|
"chats--thread-sent-label-value": t("chats.thread.sent"),
|
|
49
63
|
"chats--thread-seen-label-value": Chats.config.read_receipts ? t("chats.thread.seen") : "",
|
|
50
64
|
"chats--thread-today-label-value": t("chats.thread.today"),
|
|
@@ -67,52 +81,13 @@
|
|
|
67
81
|
|
|
68
82
|
<%= yield %>
|
|
69
83
|
|
|
70
|
-
<%# tabindex: the arrow keys scroll the history (WCAG 2.1.1).
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<%= chats_messager_avatar(counterpart) %>
|
|
78
|
-
<strong><%= Chats.display_name_for(counterpart) %></strong>
|
|
79
|
-
<% end %>
|
|
80
|
-
<%= t("chats.dock.hello", name: conversation.title_for(viewer)) %>
|
|
81
|
-
</div>
|
|
82
|
-
<% end %>
|
|
83
|
-
<% if more && messages.any? %>
|
|
84
|
-
<%# Lazy frame chain for infinite scroll-up: when this frame becomes
|
|
85
|
-
visible, Turbo fetches the previous keyset page (?before=oldest id),
|
|
86
|
-
whose response contains a frame with this SAME id — older messages
|
|
87
|
-
plus, recursively, the next frame. See ConversationsController#show. %>
|
|
88
|
-
<%= turbo_frame_tag "chats_page_#{messages.first.id}",
|
|
89
|
-
src: conversation_path(conversation, before: messages.first.id),
|
|
90
|
-
loading: :lazy do %>
|
|
91
|
-
<div class="chats-loader"><%= t("chats.thread.loading") %></div>
|
|
92
|
-
<% end %>
|
|
93
|
-
<% end %>
|
|
94
|
-
|
|
95
|
-
<div id="<%= dom_id(conversation, :messages) %>" class="chats-thread__messages">
|
|
96
|
-
<%# Explicit loop (not a collection render) so the «new messages»
|
|
97
|
-
divider can sit BEFORE the first unread bubble (WhatsApp's "the
|
|
98
|
-
catch-up starts here" line). Server-rendered on the initial page
|
|
99
|
-
only; it scrolls away naturally as the thread lives on. %>
|
|
100
|
-
<% messages.each do |message| %>
|
|
101
|
-
<% if message.id == first_unread_id %>
|
|
102
|
-
<div class="chats-thread__unread-line" role="separator">
|
|
103
|
-
<span><%= t("chats.thread.new_messages") %></span>
|
|
104
|
-
</div>
|
|
105
|
-
<% end %>
|
|
106
|
-
<%= render "chats/messages/message", message: message %>
|
|
107
|
-
<% end %>
|
|
108
|
-
</div>
|
|
109
|
-
|
|
110
|
-
<%= render "chats/conversations/read_state", conversation: conversation %>
|
|
111
|
-
|
|
112
|
-
<div id="<%= dom_id(conversation, :typing) %>"
|
|
113
|
-
class="chats-typing"
|
|
114
|
-
data-chats--thread-target="typing"
|
|
115
|
-
hidden></div>
|
|
84
|
+
<%# tabindex: the arrow keys scroll the history (WCAG 2.1.1). The id is
|
|
85
|
+
what the stale-thread catch-up re-renders when the thread fell more
|
|
86
|
+
than a page behind (ConversationsController#refresh). %>
|
|
87
|
+
<div id="<%= dom_id(conversation, :history) %>" class="chats-thread__scroll" data-chats--thread-target="scroller" tabindex="0">
|
|
88
|
+
<%= render "chats/conversations/history", conversation: conversation, messages: messages, more: more,
|
|
89
|
+
first_unread_id: first_unread_id, counterpart: counterpart,
|
|
90
|
+
window: window %>
|
|
116
91
|
</div>
|
|
117
92
|
|
|
118
93
|
<%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<%# The header over a conversation: the thread page's, and the conversation
|
|
2
|
+
pane's on the two-pane messaging page (chats/conversations/
|
|
3
|
+
_conversation_pane). Back to the list, who it is with (avatar, name,
|
|
4
|
+
the verified badge, what it is about, the way back to their stack) and
|
|
5
|
+
the ⋯ menu (mute, leave, the host's conversation_header_actions).
|
|
6
|
+
|
|
7
|
+
The back link always renders: on the two-pane page chats.css hides it
|
|
8
|
+
while the list is on screen beside the conversation.
|
|
9
|
+
|
|
10
|
+
Locals:
|
|
11
|
+
conversation the Chats::Conversation
|
|
12
|
+
participant the viewer's seat in it (mute state), or nil
|
|
13
|
+
counterpart the other messager of a direct thread, or nil
|
|
14
|
+
heading the title's element: :h1 on the thread page, :h2 in the
|
|
15
|
+
pane, under the page's own "Messages" heading
|
|
16
|
+
title_id the title's id, which a pane is labelled by (optional);
|
|
17
|
+
the pane's title also takes the focus when a row opens
|
|
18
|
+
it on a touch screen (tabindex="-1", see chats--split)
|
|
19
|
+
back_path where the back link goes (default: the inbox; the pane
|
|
20
|
+
keeps the list's search or stack) %>
|
|
21
|
+
<% heading = local_assigns.fetch(:heading, :h1) %>
|
|
22
|
+
<% title_id = local_assigns[:title_id] %>
|
|
23
|
+
<header class="chats-thread__header">
|
|
24
|
+
<%= link_to local_assigns[:back_path] || root_path, class: "chats-thread__back", "aria-label": t("chats.thread.back") do %>
|
|
25
|
+
<span aria-hidden="true">‹</span>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<%# The counterpart is already resolved for the title, the profile link
|
|
29
|
+
and the stack link — reuse it here instead of asking again. %>
|
|
30
|
+
<% if counterpart %>
|
|
31
|
+
<%= chats_messager_avatar(counterpart) %>
|
|
32
|
+
<% else %>
|
|
33
|
+
<%= chats_conversation_avatar(conversation, chats_current_messager) %>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<div class="chats-thread__identity">
|
|
37
|
+
<%# Both arms wrap the name in .chats-thread__name: the heading is a flex
|
|
38
|
+
row (the badge is its second child), and only a real element can
|
|
39
|
+
carry the ellipsis. %>
|
|
40
|
+
<%= content_tag heading, class: "chats-thread__title", id: title_id, tabindex: (-1 if title_id) do %>
|
|
41
|
+
<% if counterpart %>
|
|
42
|
+
<%= chats_messager_name(counterpart, css_class: "chats-thread__name") %>
|
|
43
|
+
<%= chats_verified_badge(counterpart) %>
|
|
44
|
+
<% else %>
|
|
45
|
+
<span class="chats-thread__name"><%= conversation.title_for(chats_current_messager) %></span>
|
|
46
|
+
<% end %>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% if conversation.subject_label %>
|
|
49
|
+
<p class="chats-thread__subject"><%= conversation.subject_label %></p>
|
|
50
|
+
<% end %>
|
|
51
|
+
<%# A stacked counterpart's thread is one of many: offer the way back
|
|
52
|
+
to the whole stack (chats' filtered inbox, or the host's own
|
|
53
|
+
screen via `group_path:`). %>
|
|
54
|
+
<% if Chats.grouped_inbox?(counterpart) %>
|
|
55
|
+
<p class="chats-thread__see-all">
|
|
56
|
+
<%= link_to t("chats.thread.see_all"), chats_group_path_for(counterpart), class: "chats-thread__see-all-link" %>
|
|
57
|
+
</p>
|
|
58
|
+
<% end %>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<details class="chats-menu">
|
|
62
|
+
<summary class="chats-menu__trigger" aria-label="<%= t("chats.thread.options") %>">⋯</summary>
|
|
63
|
+
<div class="chats-menu__panel">
|
|
64
|
+
<% if participant&.muted? %>
|
|
65
|
+
<%= button_to t("chats.thread.unmute"), unmute_conversation_path(conversation), method: :post, class: "chats-menu__item" %>
|
|
66
|
+
<% else %>
|
|
67
|
+
<%= button_to t("chats.thread.mute"), mute_conversation_path(conversation), method: :post, class: "chats-menu__item" %>
|
|
68
|
+
<% end %>
|
|
69
|
+
<% if conversation.group? %>
|
|
70
|
+
<%= button_to t("chats.thread.leave"), leave_conversation_path(conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
|
|
71
|
+
<% end %>
|
|
72
|
+
<%# Slot: the host's own thread actions (block, report, archive…).
|
|
73
|
+
`blockable` is false for messagers declared
|
|
74
|
+
`acts_as_messager blockable: false`, so safety affordances
|
|
75
|
+
disappear against a support desk without any class check. %>
|
|
76
|
+
<%= chats_slot :conversation_header_actions,
|
|
77
|
+
conversation: conversation,
|
|
78
|
+
viewer: chats_current_messager,
|
|
79
|
+
counterpart: counterpart,
|
|
80
|
+
blockable: chats_blockable?(counterpart) %>
|
|
81
|
+
</div>
|
|
82
|
+
</details>
|
|
83
|
+
</header>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# Every unread badge on the page (ConversationsController#badge), as the
|
|
2
|
+
badge streams would have sent them: the nav badge
|
|
3
|
+
(Chats::Broadcasts.update_badge_of) and the dock's (chats/dock/_update).
|
|
4
|
+
A page without one of them simply has nothing to replace. %>
|
|
5
|
+
<%= turbo_stream.replace "chats_unread_badge", partial: "chats/shared/unread_badge", locals: { count: count } %>
|
|
6
|
+
<%= turbo_stream.replace_all ".chats-dock [data-chats-unread-badge]", partial: "chats/dock/badge", locals: { count: count } %>
|
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
re-renders this page per-viewer — see Chats::Broadcasts for why refreshes
|
|
4
4
|
beat surgical row patches here.
|
|
5
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
|
+
|
|
6
25
|
Rows come from Chats::Inbox and are either a Chats::Conversation or a
|
|
7
26
|
Chats::InboxGroup (every direct thread with a `inbox: :grouped` messager,
|
|
8
27
|
stacked into one row).
|
|
@@ -10,18 +29,22 @@
|
|
|
10
29
|
The chats--refresh-inbox wrapper heals a MISSED refresh broadcast: Action
|
|
11
30
|
Cable has no replay, so a broadcast sent while the socket was down (tab
|
|
12
31
|
backgrounded, network blip) would leave the inbox stale until the user
|
|
13
|
-
navigated. The controller re-runs the same page refresh
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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. %>
|
|
17
40
|
|
|
18
41
|
<%# The OTHER inbox-staleness case, distinct from a missed broadcast: a Turbo
|
|
19
42
|
RESTORATION visit (browser back / Hotwire Native stack pop) serves the
|
|
20
43
|
inbox's cached snapshot with no GET. Open a chat from a profile, send a
|
|
21
44
|
message, tap back — the snapshot cached before the chat existed comes back
|
|
22
45
|
and the new conversation is missing until you navigate away and return.
|
|
23
|
-
The
|
|
24
|
-
|
|
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
|
|
25
48
|
uncacheable, so a restore visit re-fetches from the network and is always
|
|
26
49
|
fresh — on the web AND in Hotwire Native, whose back-pop issues a Turbo
|
|
27
50
|
`.restore` visit that honors this. Requires the host layout to
|
|
@@ -35,8 +58,9 @@
|
|
|
35
58
|
<%= turbo_stream_from chats_current_messager, :chats_inbox %>
|
|
36
59
|
</div>
|
|
37
60
|
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
<% split = chats_two_panes? %>
|
|
62
|
+
<% inbox_empty = @rows.none? && params[:q].blank? %>
|
|
63
|
+
<% list = capture do %>
|
|
40
64
|
<header class="chats-inbox__header">
|
|
41
65
|
<h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
|
|
42
66
|
</header>
|
|
@@ -49,15 +73,26 @@
|
|
|
49
73
|
<% end %>
|
|
50
74
|
|
|
51
75
|
<% if Chats.config.search && !@inbox.filtered? %>
|
|
52
|
-
|
|
53
|
-
|
|
76
|
+
<%# No `action` (so a bare tag.form: form_with always writes one): a form
|
|
77
|
+
without one submits to the page's own URL (the HTML standard, and
|
|
78
|
+
Turbo too), whatever that is NOW — /messages,
|
|
79
|
+
/messages/ (the engine answers both), or /messages/:id after a row
|
|
80
|
+
opened a conversation in the pane and moved the address bar. So the
|
|
81
|
+
URL a search puts in the address bar is still this page to Turbo,
|
|
82
|
+
the next refresh morphs it instead of re-rendering it from scratch,
|
|
83
|
+
and on the two-pane page the open conversation stays open. %>
|
|
84
|
+
<%= tag.form method: :get,
|
|
54
85
|
class: "chats-search",
|
|
55
86
|
role: "search",
|
|
56
87
|
data: {
|
|
57
88
|
controller: "chats--debounced-submit",
|
|
58
89
|
action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
|
|
59
90
|
"chats--debounced-submit-delay-value": 250,
|
|
60
|
-
turbo_frame: "chats_inbox_results"
|
|
91
|
+
turbo_frame: "chats_inbox_results",
|
|
92
|
+
# The query goes in the URL (replaced, not pushed): a page
|
|
93
|
+
# refresh re-requests the URL, so a live update or a catch-up
|
|
94
|
+
# keeps the results and the field as they are.
|
|
95
|
+
turbo_action: "replace"
|
|
61
96
|
} do %>
|
|
62
97
|
<%= search_field_tag :q, params[:q],
|
|
63
98
|
placeholder: t("chats.inbox.search_placeholder"),
|
|
@@ -75,12 +110,17 @@
|
|
|
75
110
|
<ul class="chats-inbox__list">
|
|
76
111
|
<% @rows.each do |row| %>
|
|
77
112
|
<% if row.is_a?(Chats::InboxGroup) %>
|
|
78
|
-
<%= render "chats/conversations/group", group: row, viewer: chats_current_messager
|
|
113
|
+
<%= render "chats/conversations/group", group: row, viewer: chats_current_messager,
|
|
114
|
+
pane: split, current: @conversation,
|
|
115
|
+
filter: chats_inbox_filter %>
|
|
79
116
|
<% else %>
|
|
80
117
|
<%= render "chats/conversations/conversation_row",
|
|
81
118
|
conversation: row,
|
|
82
119
|
viewer: chats_current_messager,
|
|
83
|
-
unread_count: @unread_counts.fetch(row.id, 0)
|
|
120
|
+
unread_count: @unread_counts.fetch(row.id, 0),
|
|
121
|
+
pane: split,
|
|
122
|
+
current: @conversation,
|
|
123
|
+
filter: chats_inbox_filter %>
|
|
84
124
|
<% end %>
|
|
85
125
|
<% end %>
|
|
86
126
|
</ul>
|
|
@@ -89,7 +129,8 @@
|
|
|
89
129
|
<p class="chats-empty__title"><%= t("chats.inbox.no_results_title") %></p>
|
|
90
130
|
<p class="chats-empty__hint"><%= t("chats.inbox.no_results_hint", query: params[:q]) %></p>
|
|
91
131
|
</div>
|
|
92
|
-
<%
|
|
132
|
+
<% elsif !split %>
|
|
133
|
+
<%# (Two panes: the empty pane says it, once — chats/conversations/_empty_pane.) %>
|
|
93
134
|
<div class="chats-empty">
|
|
94
135
|
<span class="chats-empty__icon" aria-hidden="true">💬</span>
|
|
95
136
|
<p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
|
|
@@ -98,4 +139,39 @@
|
|
|
98
139
|
</div>
|
|
99
140
|
<% end %>
|
|
100
141
|
<% end %>
|
|
101
|
-
|
|
142
|
+
<% end %>
|
|
143
|
+
|
|
144
|
+
<%# data-chats-dock-hide: the messaging dock hides itself on this page. %>
|
|
145
|
+
<% if split %>
|
|
146
|
+
<%= tag.div class: "chats chats-inbox chats-split",
|
|
147
|
+
data: {
|
|
148
|
+
chats_dock_hide: true,
|
|
149
|
+
controller: "chats--split",
|
|
150
|
+
action: "turbo:before-prefetch->chats--split#prefetch " \
|
|
151
|
+
"turbo:frame-load->chats--split#opened " \
|
|
152
|
+
"turbo:before-morph-attribute->chats--split#keepEnabled"
|
|
153
|
+
} do %>
|
|
154
|
+
<nav class="chats-split__list" aria-label="<%= t("chats.inbox.title") %>" data-chats--split-target="list">
|
|
155
|
+
<%= list %>
|
|
156
|
+
</nav>
|
|
157
|
+
|
|
158
|
+
<%= turbo_frame_tag Chats::ConversationsController::PANE_FRAME,
|
|
159
|
+
class: "chats-split__conversation",
|
|
160
|
+
target: "_top",
|
|
161
|
+
disabled: true,
|
|
162
|
+
data: { turbo_action: "advance", "chats--split-target": "frame" } do %>
|
|
163
|
+
<% if @conversation %>
|
|
164
|
+
<%= render "chats/conversations/conversation_pane",
|
|
165
|
+
conversation: @conversation, participant: @participant,
|
|
166
|
+
messages: @messages, more: @more_messages, first_unread_id: @first_unread_id,
|
|
167
|
+
counterpart: chats_counterpart, read_pending: @read_pending %>
|
|
168
|
+
<% else %>
|
|
169
|
+
<%= render "chats/conversations/empty_pane", inbox_empty: inbox_empty, inbox: @inbox %>
|
|
170
|
+
<% end %>
|
|
171
|
+
<% end %>
|
|
172
|
+
<% end %>
|
|
173
|
+
<% else %>
|
|
174
|
+
<div class="chats chats-inbox" data-chats-dock-hide>
|
|
175
|
+
<%= list %>
|
|
176
|
+
</div>
|
|
177
|
+
<% end %>
|
|
@@ -0,0 +1,12 @@
|
|
|
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, no layout (ConversationsController#show).
|
|
5
|
+
Turbo swaps it into the frame and, the frame being
|
|
6
|
+
data-turbo-action="advance", moves the address bar to /messages/:id. %>
|
|
7
|
+
<%= turbo_frame_tag Chats::ConversationsController::PANE_FRAME do %>
|
|
8
|
+
<%= render "chats/conversations/conversation_pane",
|
|
9
|
+
conversation: @conversation, participant: @participant,
|
|
10
|
+
messages: @messages, more: @more_messages, first_unread_id: @first_unread_id,
|
|
11
|
+
counterpart: chats_counterpart, read_pending: @read_pending %>
|
|
12
|
+
<% 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 %>
|