chats 0.5.1 → 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 +118 -0
- data/README.md +36 -6
- data/app/assets/stylesheets/chats.css +111 -0
- data/app/controllers/chats/conversations_controller.rb +101 -11
- data/app/javascript/chats/dock_controller.js +15 -1
- data/app/javascript/chats/split_controller.js +97 -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/_thread.html.erb +22 -8
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/index.html.erb +77 -14
- data/app/views/chats/conversations/pane.html.erb +12 -0
- data/app/views/chats/conversations/show.html.erb +10 -65
- data/app/views/chats/dock/_dock.html.erb +2 -1
- 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 +1 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -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/configuration.rb +23 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +9 -0
- metadata +16 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// chats--split — the two-pane messaging page (`config.inbox_layout = :split`).
|
|
4
|
+
//
|
|
5
|
+
// chats.css lays the page out on its own, with one media query: both panes
|
|
6
|
+
// from 700px wide, one below. What CSS can't decide is where a row's link
|
|
7
|
+
// goes, and that is all this does:
|
|
8
|
+
//
|
|
9
|
+
// * wide: the conversation frame is enabled, so a row opens its
|
|
10
|
+
// conversation beside the list, in one request for the pane alone, and
|
|
11
|
+
// the address bar moves to /messages/:id (the frame's
|
|
12
|
+
// data-turbo-action="advance");
|
|
13
|
+
// * narrow: the frame stays `disabled`, so the same link opens the
|
|
14
|
+
// conversation's page, which then fills the screen. A disabled frame is
|
|
15
|
+
// one Turbo never navigates, links naming it included. And no hover
|
|
16
|
+
// prefetch: the link names the frame, so Turbo would ask for the pane
|
|
17
|
+
// alone, and the page visit would then render that answer as the page.
|
|
18
|
+
//
|
|
19
|
+
// The server always renders the frame disabled, because a page visit is
|
|
20
|
+
// right at any width: it is what a click does before this connects, and
|
|
21
|
+
// without JavaScript. So the page's refreshes (Turbo 8 morphs, every live
|
|
22
|
+
// update of the list) would disable it again on a wide screen; the frame
|
|
23
|
+
// keeps what the screen says.
|
|
24
|
+
//
|
|
25
|
+
// When the pane opens a conversation, its row becomes the current one
|
|
26
|
+
// (aria-current="page", .chats-row--current) at once, exactly as the server
|
|
27
|
+
// renders /messages/:id, instead of when the list next refreshes. And the
|
|
28
|
+
// focus goes where the viewer is now, as LinkedIn's messaging page does:
|
|
29
|
+
// into the conversation's composer, ready to reply. Entering the pane, a
|
|
30
|
+
// screen reader names it (a section labelled by the conversation's title),
|
|
31
|
+
// then the field. On a touch screen the composer would raise the keyboard
|
|
32
|
+
// over a conversation not read yet, so the focus goes to the pane's title
|
|
33
|
+
// instead (tabindex="-1"), which is read out the same; and to the title
|
|
34
|
+
// too when there is nothing to write in (a locked conversation).
|
|
35
|
+
//
|
|
36
|
+
// Registered as "chats--split" through the engine's importmap pin.
|
|
37
|
+
const WIDE = "(min-width: 700px)" // keep in step with chats.css
|
|
38
|
+
const TOUCH = "(pointer: coarse)"
|
|
39
|
+
|
|
40
|
+
export default class extends Controller {
|
|
41
|
+
static targets = ["frame", "list"]
|
|
42
|
+
|
|
43
|
+
connect() {
|
|
44
|
+
this.media = window.matchMedia?.(WIDE)
|
|
45
|
+
this.media?.addEventListener?.("change", this.layout)
|
|
46
|
+
this.layout()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
disconnect() {
|
|
50
|
+
this.media?.removeEventListener?.("change", this.layout)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
layout = () => {
|
|
54
|
+
if (this.hasFrameTarget) this.frameTarget.toggleAttribute("disabled", !this.media?.matches)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// turbo:before-morph-attribute: a refresh leaves the frame as the screen has it.
|
|
58
|
+
keepEnabled(event) {
|
|
59
|
+
if (event.target === this.frameTarget && event.detail?.attributeName === "disabled") event.preventDefault()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// turbo:before-prefetch: nothing names a frame that can't take it.
|
|
63
|
+
prefetch(event) {
|
|
64
|
+
if (!this.hasFrameTarget || !this.frameTarget.hasAttribute("disabled")) return
|
|
65
|
+
if (event.target?.getAttribute?.("data-turbo-frame") === this.frameTarget.id) event.preventDefault()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// turbo:frame-load: the pane opened a conversation. Its row is current,
|
|
69
|
+
// and the viewer is taken to it.
|
|
70
|
+
opened(event) {
|
|
71
|
+
if (!this.hasFrameTarget || event.target !== this.frameTarget || !this.hasListTarget) return
|
|
72
|
+
const src = this.frameTarget.getAttribute("src")
|
|
73
|
+
if (!src) return
|
|
74
|
+
|
|
75
|
+
const open = new URL(src, document.baseURI).pathname
|
|
76
|
+
this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
|
|
77
|
+
const current = new URL(link.getAttribute("href"), document.baseURI).pathname === open
|
|
78
|
+
if (current) {
|
|
79
|
+
link.setAttribute("aria-current", "page")
|
|
80
|
+
} else {
|
|
81
|
+
link.removeAttribute("aria-current")
|
|
82
|
+
}
|
|
83
|
+
link.closest(".chats-row")?.classList.toggle("chats-row--current", current)
|
|
84
|
+
})
|
|
85
|
+
this.arrive()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
arrive() {
|
|
89
|
+
const pane = this.frameTarget.querySelector(".chats-split__pane")
|
|
90
|
+
if (!pane) return
|
|
91
|
+
|
|
92
|
+
const touch = Boolean(window.matchMedia?.(TOUCH)?.matches)
|
|
93
|
+
const composer = touch ? null : pane.querySelector(".chats-composer__input")
|
|
94
|
+
const title = pane.querySelector(`[id="${pane.getAttribute("aria-labelledby")}"]`)
|
|
95
|
+
;(composer || title)?.focus({ preventScroll: true })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -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">
|
|
@@ -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"),
|
|
@@ -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>
|
|
@@ -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).
|
|
@@ -39,8 +58,9 @@
|
|
|
39
58
|
<%= turbo_stream_from chats_current_messager, :chats_inbox %>
|
|
40
59
|
</div>
|
|
41
60
|
|
|
42
|
-
|
|
43
|
-
|
|
61
|
+
<% split = chats_two_panes? %>
|
|
62
|
+
<% inbox_empty = @rows.none? && params[:q].blank? %>
|
|
63
|
+
<% list = capture do %>
|
|
44
64
|
<header class="chats-inbox__header">
|
|
45
65
|
<h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
|
|
46
66
|
</header>
|
|
@@ -53,15 +73,17 @@
|
|
|
53
73
|
<% end %>
|
|
54
74
|
|
|
55
75
|
<% if Chats.config.search && !@inbox.filtered? %>
|
|
56
|
-
<%#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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,
|
|
62
85
|
class: "chats-search",
|
|
63
|
-
|
|
64
|
-
html: { role: "search" },
|
|
86
|
+
role: "search",
|
|
65
87
|
data: {
|
|
66
88
|
controller: "chats--debounced-submit",
|
|
67
89
|
action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
|
|
@@ -88,12 +110,17 @@
|
|
|
88
110
|
<ul class="chats-inbox__list">
|
|
89
111
|
<% @rows.each do |row| %>
|
|
90
112
|
<% if row.is_a?(Chats::InboxGroup) %>
|
|
91
|
-
<%= 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 %>
|
|
92
116
|
<% else %>
|
|
93
117
|
<%= render "chats/conversations/conversation_row",
|
|
94
118
|
conversation: row,
|
|
95
119
|
viewer: chats_current_messager,
|
|
96
|
-
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 %>
|
|
97
124
|
<% end %>
|
|
98
125
|
<% end %>
|
|
99
126
|
</ul>
|
|
@@ -102,7 +129,8 @@
|
|
|
102
129
|
<p class="chats-empty__title"><%= t("chats.inbox.no_results_title") %></p>
|
|
103
130
|
<p class="chats-empty__hint"><%= t("chats.inbox.no_results_hint", query: params[:q]) %></p>
|
|
104
131
|
</div>
|
|
105
|
-
<%
|
|
132
|
+
<% elsif !split %>
|
|
133
|
+
<%# (Two panes: the empty pane says it, once — chats/conversations/_empty_pane.) %>
|
|
106
134
|
<div class="chats-empty">
|
|
107
135
|
<span class="chats-empty__icon" aria-hidden="true">💬</span>
|
|
108
136
|
<p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
|
|
@@ -111,4 +139,39 @@
|
|
|
111
139
|
</div>
|
|
112
140
|
<% end %>
|
|
113
141
|
<% end %>
|
|
114
|
-
|
|
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,6 +1,9 @@
|
|
|
1
|
-
<%# The thread page
|
|
2
|
-
(chats/conversations/
|
|
3
|
-
|
|
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). %>
|
|
4
7
|
<%= chats_styles %>
|
|
5
8
|
|
|
6
9
|
<%= render layout: "chats/conversations/thread",
|
|
@@ -9,67 +12,9 @@
|
|
|
9
12
|
messages: @messages,
|
|
10
13
|
more: @more_messages,
|
|
11
14
|
first_unread_id: @first_unread_id,
|
|
12
|
-
counterpart: chats_counterpart
|
|
15
|
+
counterpart: chats_counterpart,
|
|
16
|
+
read_pending: @read_pending
|
|
13
17
|
} do %>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<span aria-hidden="true">‹</span>
|
|
17
|
-
<% end %>
|
|
18
|
-
|
|
19
|
-
<%# The counterpart is already resolved for the title, the profile link
|
|
20
|
-
and the stack link — reuse it here instead of asking again. %>
|
|
21
|
-
<% if chats_counterpart %>
|
|
22
|
-
<%= chats_messager_avatar(chats_counterpart) %>
|
|
23
|
-
<% else %>
|
|
24
|
-
<%= chats_conversation_avatar(@conversation, chats_current_messager) %>
|
|
25
|
-
<% end %>
|
|
26
|
-
|
|
27
|
-
<div class="chats-thread__identity">
|
|
28
|
-
<%# Both arms wrap the name in .chats-thread__name: the <h1> is a flex
|
|
29
|
-
row (the badge is its second child), and only a real element can
|
|
30
|
-
carry the ellipsis. %>
|
|
31
|
-
<h1 class="chats-thread__title">
|
|
32
|
-
<% if chats_counterpart %>
|
|
33
|
-
<%= chats_messager_name(chats_counterpart, css_class: "chats-thread__name") %>
|
|
34
|
-
<%= chats_verified_badge(chats_counterpart) %>
|
|
35
|
-
<% else %>
|
|
36
|
-
<span class="chats-thread__name"><%= @conversation.title_for(chats_current_messager) %></span>
|
|
37
|
-
<% end %>
|
|
38
|
-
</h1>
|
|
39
|
-
<% if @conversation.subject_label %>
|
|
40
|
-
<p class="chats-thread__subject"><%= @conversation.subject_label %></p>
|
|
41
|
-
<% end %>
|
|
42
|
-
<%# A stacked counterpart's thread is one of many: offer the way back
|
|
43
|
-
to the whole stack (chats' filtered inbox, or the host's own
|
|
44
|
-
screen via `group_path:`). %>
|
|
45
|
-
<% if Chats.grouped_inbox?(chats_counterpart) %>
|
|
46
|
-
<p class="chats-thread__see-all">
|
|
47
|
-
<%= link_to t("chats.thread.see_all"), chats_group_path_for(chats_counterpart), class: "chats-thread__see-all-link" %>
|
|
48
|
-
</p>
|
|
49
|
-
<% end %>
|
|
50
|
-
</div>
|
|
51
|
-
|
|
52
|
-
<details class="chats-menu">
|
|
53
|
-
<summary class="chats-menu__trigger" aria-label="<%= t("chats.thread.options") %>">⋯</summary>
|
|
54
|
-
<div class="chats-menu__panel">
|
|
55
|
-
<% if @participant&.muted? %>
|
|
56
|
-
<%= button_to t("chats.thread.unmute"), unmute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
|
|
57
|
-
<% else %>
|
|
58
|
-
<%= button_to t("chats.thread.mute"), mute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
|
|
59
|
-
<% end %>
|
|
60
|
-
<% if @conversation.group? %>
|
|
61
|
-
<%= button_to t("chats.thread.leave"), leave_conversation_path(@conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
|
|
62
|
-
<% end %>
|
|
63
|
-
<%# Slot: the host's own thread actions (block, report, archive…).
|
|
64
|
-
`blockable` is false for messagers declared
|
|
65
|
-
`acts_as_messager blockable: false`, so safety affordances
|
|
66
|
-
disappear against a support desk without any class check. %>
|
|
67
|
-
<%= chats_slot :conversation_header_actions,
|
|
68
|
-
conversation: @conversation,
|
|
69
|
-
viewer: chats_current_messager,
|
|
70
|
-
counterpart: chats_counterpart,
|
|
71
|
-
blockable: chats_blockable?(chats_counterpart) %>
|
|
72
|
-
</div>
|
|
73
|
-
</details>
|
|
74
|
-
</header>
|
|
18
|
+
<%= render "chats/conversations/thread_header",
|
|
19
|
+
conversation: @conversation, participant: @participant, counterpart: chats_counterpart %>
|
|
75
20
|
<% end %>
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"input->chats--dock#typed " \
|
|
50
50
|
"click@document->chats--dock#delegate " \
|
|
51
51
|
"turbo:before-stream-render@document->chats--dock#beforeStreamRender " \
|
|
52
|
-
"turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged"
|
|
52
|
+
"turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged " \
|
|
53
|
+
"turbo:frame-load@document->chats--dock#pageFrameLoaded"
|
|
53
54
|
} do %>
|
|
54
55
|
<%= turbo_stream_from viewer, :chats_dock %>
|
|
55
56
|
<%= render "chats/dock/signal" %>
|
data/chats-0.3.2.gem
ADDED
|
Binary file
|
data/chats-0.4.0.gem
ADDED
|
Binary file
|
data/chats-0.4.1.gem
ADDED
|
Binary file
|
data/chats-0.4.2.gem
ADDED
|
Binary file
|
data/chats-0.5.0.gem
ADDED
|
Binary file
|
data/chats-0.5.1.gem
ADDED
|
Binary file
|
data/config/importmap.rb
CHANGED
|
@@ -17,6 +17,7 @@ pin "controllers/chats/debounced_submit_controller", to: "chats/debounced_submit
|
|
|
17
17
|
pin "controllers/chats/refresh_inbox_controller", to: "chats/refresh_inbox_controller.js"
|
|
18
18
|
pin "controllers/chats/dock_controller", to: "chats/dock_controller.js"
|
|
19
19
|
pin "controllers/chats/unread_badge_controller", to: "chats/unread_badge_controller.js"
|
|
20
|
+
pin "controllers/chats/split_controller", to: "chats/split_controller.js"
|
|
20
21
|
|
|
21
22
|
# Not a controller: what the inbox, thread, badge and dock controllers share
|
|
22
23
|
# to catch up on broadcasts they missed. Outside controllers/ so the eager
|