chats 0.5.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 +171 -0
- data/README.md +40 -7
- data/app/assets/stylesheets/chats.css +131 -6
- data/app/controllers/chats/conversations_controller.rb +106 -11
- data/app/helpers/chats/engine_helper.rb +15 -0
- data/app/javascript/chats/dock_controller.js +15 -1
- data/app/javascript/chats/split_controller.js +131 -0
- data/app/javascript/chats/thread_controller.js +5 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +53 -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 +81 -14
- data/app/views/chats/conversations/pane.html.erb +14 -0
- data/app/views/chats/conversations/show.html.erb +11 -65
- data/app/views/chats/dock/_dock.html.erb +2 -1
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +4 -0
- data/config/locales/es.yml +4 -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 +36 -0
- data/lib/chats/models/conversation.rb +10 -2
- data/lib/chats/models/message.rb +19 -3
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +17 -0
- metadata +11 -3
- data/chats-0.3.1.gem +0 -0
|
@@ -14,9 +14,12 @@ module Chats
|
|
|
14
14
|
# 36): a client past either gets its history re-rendered instead.
|
|
15
15
|
CURSOR_IDS_LIMIT = 200
|
|
16
16
|
CURSOR_ID_LENGTH = 64
|
|
17
|
+
# The two-pane messaging page's conversation frame (index.html.erb): a
|
|
18
|
+
# request from it is answered with the pane alone (pane.html.erb).
|
|
19
|
+
PANE_FRAME = "chats_conversation"
|
|
17
20
|
|
|
18
21
|
before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
|
|
19
|
-
helper_method :chats_counterpart
|
|
22
|
+
helper_method :chats_counterpart, :chats_two_panes?, :chats_inbox_filter
|
|
20
23
|
|
|
21
24
|
# The inbox. Everything is preloaded/batched so rendering N rows costs a
|
|
22
25
|
# constant number of queries (conversations + last messages + participants
|
|
@@ -29,14 +32,7 @@ module Chats
|
|
|
29
32
|
# is the dock's own list.
|
|
30
33
|
return dock if turbo_frame_request_id == "chats_dock_list"
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
@rows = @inbox.rows
|
|
34
|
-
@unread_counts = @inbox.unread_counts
|
|
35
|
-
# Back-compat for inboxes ejected under 0.1.x, which loop over
|
|
36
|
-
# @conversations: they keep rendering the flat list (no stacking, i.e.
|
|
37
|
-
# exactly what they rendered before). Re-eject or delete your copy to
|
|
38
|
-
# get the stacked rows.
|
|
39
|
-
@conversations = @inbox.conversations
|
|
35
|
+
load_inbox
|
|
40
36
|
end
|
|
41
37
|
|
|
42
38
|
# The messaging dock's recent list (`chats_dock`): the first rows of the
|
|
@@ -67,6 +63,12 @@ module Chats
|
|
|
67
63
|
# `?window=1` from a Turbo frame is the same thread as a floating dock
|
|
68
64
|
# window: the window template (its compact header around the same
|
|
69
65
|
# thread body), no layout.
|
|
66
|
+
#
|
|
67
|
+
# With two panes (`config.inbox_layout = :split`, the default) the
|
|
68
|
+
# thread is the conversation pane of the messaging page: the whole page
|
|
69
|
+
# (the inbox's template, with the pane open), or, asked from that page's
|
|
70
|
+
# own conversation frame (a row clicked on a wide screen), the pane
|
|
71
|
+
# alone. `:stacked` renders the thread page.
|
|
70
72
|
def show
|
|
71
73
|
@participant = @conversation.participant_for(chats_current_messager)
|
|
72
74
|
|
|
@@ -90,10 +92,14 @@ module Chats
|
|
|
90
92
|
# swallow the unread badge. The thread controller posts #read once
|
|
91
93
|
# the window is actually on screen (`read-pending`).
|
|
92
94
|
render :window, layout: false
|
|
93
|
-
|
|
95
|
+
else
|
|
94
96
|
# Opening the thread reads it. (Live appends while the thread
|
|
95
97
|
# stays open are read via the thread controller's POST to #read.)
|
|
96
|
-
|
|
98
|
+
# A render the server doesn't read is marked `read-pending`: the
|
|
99
|
+
# thread controller reads it once it is on screen.
|
|
100
|
+
@read_pending = !reads_on_render?
|
|
101
|
+
@participant&.read! unless @read_pending
|
|
102
|
+
render_split_page if chats_two_panes?
|
|
97
103
|
end
|
|
98
104
|
end
|
|
99
105
|
end
|
|
@@ -226,6 +232,95 @@ module Chats
|
|
|
226
232
|
@conversation = find_conversation
|
|
227
233
|
end
|
|
228
234
|
|
|
235
|
+
# The inbox's rows, for the inbox page and for the two-pane page.
|
|
236
|
+
def load_inbox
|
|
237
|
+
@inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
|
|
238
|
+
@rows = @inbox.rows
|
|
239
|
+
@unread_counts = @inbox.unread_counts
|
|
240
|
+
# Back-compat for inboxes ejected under 0.1.x, which loop over
|
|
241
|
+
# @conversations: they keep rendering the flat list (no stacking, i.e.
|
|
242
|
+
# exactly what they rendered before). Re-eject or delete your copy to
|
|
243
|
+
# get the stacked rows.
|
|
244
|
+
@conversations = @inbox.conversations
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The open conversation on the two-pane page: the pane alone for the
|
|
248
|
+
# page's own conversation frame, else the whole page around it (any
|
|
249
|
+
# other frame of the page, the search's results among them, finds its
|
|
250
|
+
# own part in it).
|
|
251
|
+
#
|
|
252
|
+
# The pane alone comes in turbo-rails' minimal frame layout, always (even
|
|
253
|
+
# under a `config.layout`): nothing of the host's page is rendered for
|
|
254
|
+
# Turbo to throw away. Its title travels on the pane (`data-title`,
|
|
255
|
+
# chats_page_title), since Turbo leaves <head> alone on a frame visit.
|
|
256
|
+
def render_split_page
|
|
257
|
+
return render :pane, layout: "turbo_rails/frame" if pane_request?
|
|
258
|
+
|
|
259
|
+
load_inbox
|
|
260
|
+
render :index
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def pane_request?
|
|
264
|
+
turbo_frame_request_id == PANE_FRAME
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Whether the messaging page is two panes: `config.inbox_layout = :split`
|
|
268
|
+
# (the default), unless this app ejected its views before there were
|
|
269
|
+
# two panes. `rails g chats:views` under 0.5 copied an inbox and a
|
|
270
|
+
# thread page and no conversation pane: that app keeps its own separate
|
|
271
|
+
# pages, rendered and read exactly as before, until it ejects again (or
|
|
272
|
+
# deletes its copies). Views ejected since 0.6 carry the pane, and are
|
|
273
|
+
# two panes like the gem's own.
|
|
274
|
+
def chats_two_panes?
|
|
275
|
+
return @chats_two_panes if defined?(@chats_two_panes)
|
|
276
|
+
|
|
277
|
+
@chats_two_panes = Chats.config.split_inbox? && !views_ejected_before_two_panes?
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The list's filter on the two-pane page: its search (`q`) or the stack
|
|
281
|
+
# it shows (`with`). A row opened in the pane carries it into its URL,
|
|
282
|
+
# /messages/:id?with=…, because that URL is what the list's live
|
|
283
|
+
# refreshes re-request: without it, the next refresh would bring back
|
|
284
|
+
# the whole inbox under a list that was filtered. The back link to the
|
|
285
|
+
# list keeps it too.
|
|
286
|
+
def chats_inbox_filter
|
|
287
|
+
{ q: params[:q], with: params[:with] }.select { |_, value| value.is_a?(String) && value.present? }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def views_ejected_before_two_panes?
|
|
291
|
+
(own_view?("index") || own_view?("show")) && !own_view?("conversation_pane", partial: true)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Whether the app renders its own copy of one of these views, not the gem's.
|
|
295
|
+
def own_view?(name, partial: false)
|
|
296
|
+
template = lookup_context.find_all(name, ["chats/conversations"], partial).first
|
|
297
|
+
template.present? && !template.identifier.to_s.start_with?(Chats::Engine.root.join("app/views").to_s)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Whether showing the conversation reads it, there and then.
|
|
301
|
+
#
|
|
302
|
+
# Never a prefetch (Turbo 8 fetches a link the pointer rests on, which
|
|
303
|
+
# is nobody reading). On the thread page, everything else: opening it
|
|
304
|
+
# reads it.
|
|
305
|
+
#
|
|
306
|
+
# On the two-pane page, the viewer opening it: a row clicked (the
|
|
307
|
+
# pane's frame) or the page loaded by the browser itself (a link from
|
|
308
|
+
# elsewhere, a reload, no JavaScript). NOT a Turbo visit of the page,
|
|
309
|
+
# because one of those is the inbox's own refresh: every live update of
|
|
310
|
+
# the list re-requests this page, maybe in a hidden tab, and a read
|
|
311
|
+
# there would say "Seen" to the sender while nobody looks — and, since
|
|
312
|
+
# a read refreshes the reader's inbox, refresh the page again, forever.
|
|
313
|
+
# Turbo marks each of its requests (X-Turbo-Request-Id) and a refresh
|
|
314
|
+
# looks like any other visit, so a Turbo visit renders the pane unread
|
|
315
|
+
# and its thread reads it once it's on screen. A refresh's pane never
|
|
316
|
+
# gets that far: the page keeps its permanent one, and drops the new.
|
|
317
|
+
def reads_on_render?
|
|
318
|
+
return false if prefetch?
|
|
319
|
+
return true unless chats_two_panes?
|
|
320
|
+
|
|
321
|
+
pane_request? || request.headers["X-Turbo-Request-Id"].blank?
|
|
322
|
+
end
|
|
323
|
+
|
|
229
324
|
def time_from_ms(value)
|
|
230
325
|
Time.zone.at(0, value.to_i, :millisecond)
|
|
231
326
|
end
|
|
@@ -329,6 +329,21 @@ module Chats
|
|
|
329
329
|
path.presence || chats_routes.conversations_path(with: Chats.inbox_with_sgid(messager))
|
|
330
330
|
end
|
|
331
331
|
|
|
332
|
+
# The messaging page's title: the inbox's, or +conversation+'s.
|
|
333
|
+
# `config.page_title` when the host sets one, else the localized
|
|
334
|
+
# `chats.page_title.*`. ONE source for both ways a conversation opens:
|
|
335
|
+
# the bundled pages hand it to `content_for :title` on a full visit, and
|
|
336
|
+
# the two-pane page's pane carries it (`data-title`) for chats--split to
|
|
337
|
+
# give the tab when a row opens it, since Turbo leaves <head> alone then.
|
|
338
|
+
def chats_page_title(conversation = nil)
|
|
339
|
+
viewer = chats_current_messager
|
|
340
|
+
custom = Chats.config.page_title&.call(conversation, viewer)
|
|
341
|
+
return custom.to_s if custom.present?
|
|
342
|
+
return t("chats.page_title.inbox") unless conversation
|
|
343
|
+
|
|
344
|
+
t("chats.page_title.conversation", name: conversation.title_for(viewer))
|
|
345
|
+
end
|
|
346
|
+
|
|
332
347
|
# The gem's bundled stylesheet (CSS-variable themed — see chats.css).
|
|
333
348
|
# Called from the engine's own views; hosts that eject + restyle the
|
|
334
349
|
# views with their own framework simply don't include it.
|
|
@@ -44,7 +44,9 @@ import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
|
|
|
44
44
|
// Every access is guarded: no storage, no memory, no harm.
|
|
45
45
|
// * hiding on any page with [data-chats-dock-hide] (the engine's
|
|
46
46
|
// inbox and thread), since a permanent element is never
|
|
47
|
-
// re-rendered by the server
|
|
47
|
+
// re-rendered by the server; and no window for a
|
|
48
|
+
// conversation the page renders, after a visit or after a
|
|
49
|
+
// frame of the page loads one
|
|
48
50
|
// * keyboard Escape unwinds innermost first: the thread's popup or
|
|
49
51
|
// photo, an edit, the window menu, then the window
|
|
50
52
|
// (minimized), the panel (closed); on a phone, the window
|
|
@@ -782,6 +784,18 @@ export default class extends Controller {
|
|
|
782
784
|
this.save()
|
|
783
785
|
}
|
|
784
786
|
|
|
787
|
+
// A frame of the page itself loaded a conversation (the two-pane messaging
|
|
788
|
+
// page opens one beside its list without a visit): no window keeps a
|
|
789
|
+
// second copy of it.
|
|
790
|
+
pageFrameLoaded(event) {
|
|
791
|
+
if (this.element.contains(event.target)) return
|
|
792
|
+
|
|
793
|
+
this.windowTargets.forEach((frame) => {
|
|
794
|
+
if (this.renderedOnPage(frame.dataset.conversationId)) this.removeWindow(frame)
|
|
795
|
+
})
|
|
796
|
+
this.save()
|
|
797
|
+
}
|
|
798
|
+
|
|
785
799
|
// A row whose conversation is open in a window says so.
|
|
786
800
|
markOpenRows() {
|
|
787
801
|
if (!this.hasListTarget) return
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
// The tab's title follows the pane: Turbo leaves <head> alone when it
|
|
26
|
+
// promotes a frame load to a visit, so the title would stay the previous
|
|
27
|
+
// page's. The pane carries the page's title with it open (`data-title`,
|
|
28
|
+
// chats_page_title: the same the full page gives `content_for :title`).
|
|
29
|
+
//
|
|
30
|
+
// The open conversation's row stops counting it unread as soon as it is
|
|
31
|
+
// read (the pane's answer says the server read it, `data-read`; or its
|
|
32
|
+
// thread reads it on screen, `chats:read`), without waiting for the list's
|
|
33
|
+
// live refresh, which may be seconds away or not come at all.
|
|
34
|
+
//
|
|
35
|
+
// When the pane opens a conversation, its row becomes the current one
|
|
36
|
+
// (aria-current="page", .chats-row--current) at once, exactly as the server
|
|
37
|
+
// renders /messages/:id, instead of when the list next refreshes. And the
|
|
38
|
+
// focus goes where the viewer is now, as LinkedIn's messaging page does:
|
|
39
|
+
// into the conversation's composer, ready to reply. Entering the pane, a
|
|
40
|
+
// screen reader names it (a section labelled by the conversation's title),
|
|
41
|
+
// then the field. On a touch screen the composer would raise the keyboard
|
|
42
|
+
// over a conversation not read yet, so the focus goes to the pane's title
|
|
43
|
+
// instead (tabindex="-1"), which is read out the same; and to the title
|
|
44
|
+
// too when there is nothing to write in (a locked conversation).
|
|
45
|
+
//
|
|
46
|
+
// Registered as "chats--split" through the engine's importmap pin.
|
|
47
|
+
const WIDE = "(min-width: 700px)" // keep in step with chats.css
|
|
48
|
+
const TOUCH = "(pointer: coarse)"
|
|
49
|
+
|
|
50
|
+
export default class extends Controller {
|
|
51
|
+
static targets = ["frame", "list"]
|
|
52
|
+
|
|
53
|
+
connect() {
|
|
54
|
+
this.media = window.matchMedia?.(WIDE)
|
|
55
|
+
this.media?.addEventListener?.("change", this.layout)
|
|
56
|
+
this.layout()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
disconnect() {
|
|
60
|
+
this.media?.removeEventListener?.("change", this.layout)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
layout = () => {
|
|
64
|
+
if (this.hasFrameTarget) this.frameTarget.toggleAttribute("disabled", !this.media?.matches)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// turbo:before-morph-attribute: a refresh leaves the frame as the screen has it.
|
|
68
|
+
keepEnabled(event) {
|
|
69
|
+
if (event.target === this.frameTarget && event.detail?.attributeName === "disabled") event.preventDefault()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// turbo:before-prefetch: nothing names a frame that can't take it.
|
|
73
|
+
prefetch(event) {
|
|
74
|
+
if (!this.hasFrameTarget || !this.frameTarget.hasAttribute("disabled")) return
|
|
75
|
+
if (event.target?.getAttribute?.("data-turbo-frame") === this.frameTarget.id) event.preventDefault()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// turbo:frame-load: the pane opened a conversation. Its row is current,
|
|
79
|
+
// and the viewer is taken to it.
|
|
80
|
+
opened(event) {
|
|
81
|
+
if (!this.hasFrameTarget || event.target !== this.frameTarget || !this.hasListTarget) return
|
|
82
|
+
const src = this.frameTarget.getAttribute("src")
|
|
83
|
+
if (!src) return
|
|
84
|
+
|
|
85
|
+
const open = new URL(src, document.baseURI).pathname
|
|
86
|
+
this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
|
|
87
|
+
const current = new URL(link.getAttribute("href"), document.baseURI).pathname === open
|
|
88
|
+
if (current) {
|
|
89
|
+
link.setAttribute("aria-current", "page")
|
|
90
|
+
} else {
|
|
91
|
+
link.removeAttribute("aria-current")
|
|
92
|
+
}
|
|
93
|
+
link.closest(".chats-row")?.classList.toggle("chats-row--current", current)
|
|
94
|
+
})
|
|
95
|
+
const pane = this.frameTarget.querySelector(".chats-split__pane")
|
|
96
|
+
if (pane?.dataset.title) document.title = pane.dataset.title
|
|
97
|
+
if (pane?.dataset.read === "true") this.markRead(pane)
|
|
98
|
+
this.arrive()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// chats:read, from the pane's thread.
|
|
102
|
+
threadRead(event) {
|
|
103
|
+
const pane = event.target?.closest?.(".chats-split__pane")
|
|
104
|
+
if (pane) this.markRead(pane)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The conversation's row, read: no count, no bold, as the next refresh
|
|
108
|
+
// will render it.
|
|
109
|
+
markRead(pane) {
|
|
110
|
+
if (!this.hasListTarget || !pane.dataset.path) return
|
|
111
|
+
|
|
112
|
+
const path = new URL(pane.dataset.path, document.baseURI).pathname
|
|
113
|
+
this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
|
|
114
|
+
if (new URL(link.getAttribute("href"), document.baseURI).pathname !== path) return
|
|
115
|
+
|
|
116
|
+
const row = link.closest(".chats-row")
|
|
117
|
+
row?.classList.remove("chats-row--unread")
|
|
118
|
+
row?.querySelector(".chats-row__bottom .chats-badge")?.remove()
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
arrive() {
|
|
123
|
+
const pane = this.frameTarget.querySelector(".chats-split__pane")
|
|
124
|
+
if (!pane) return
|
|
125
|
+
|
|
126
|
+
const touch = Boolean(window.matchMedia?.(TOUCH)?.matches)
|
|
127
|
+
const composer = touch ? null : pane.querySelector(".chats-composer__input")
|
|
128
|
+
const title = pane.querySelector(`[id="${pane.getAttribute("aria-labelledby")}"]`)
|
|
129
|
+
;(composer || title)?.focus({ preventScroll: true })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -456,9 +456,14 @@ export default class extends Controller {
|
|
|
456
456
|
this.pendingRead = true
|
|
457
457
|
return
|
|
458
458
|
}
|
|
459
|
+
// chats:read, once the server has it: the two-pane page clears the
|
|
460
|
+
// conversation's row (chats--split) without waiting for the list's
|
|
461
|
+
// live refresh.
|
|
459
462
|
fetch(this.readUrlValue, {
|
|
460
463
|
method: "POST",
|
|
461
464
|
headers: { "X-CSRF-Token": csrfToken(), Accept: "application/json" }
|
|
465
|
+
}).then((response) => {
|
|
466
|
+
if (response.ok) this.element.dispatchEvent(new CustomEvent("chats:read", { bubbles: true }))
|
|
462
467
|
}).catch(() => {})
|
|
463
468
|
}
|
|
464
469
|
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
data-title: the page's title with this conversation open
|
|
21
|
+
(chats_page_title, the same the full page gives `content_for :title`):
|
|
22
|
+
chats--split gives it to the tab when a row opens the pane, since Turbo
|
|
23
|
+
leaves <head> alone on a frame visit.
|
|
24
|
+
|
|
25
|
+
data-read: the server read the conversation as it rendered this (a row
|
|
26
|
+
clicked); chats--split then clears its row's unread count at once,
|
|
27
|
+
instead of when the list next refreshes. A pane rendered unread clears
|
|
28
|
+
it when its thread reads (the thread's chats:read event).
|
|
29
|
+
|
|
30
|
+
Locals: conversation, participant, messages, more, first_unread_id,
|
|
31
|
+
counterpart, read_pending (as in chats/conversations/_thread). %>
|
|
32
|
+
<% title_id = dom_id(conversation, :title) %>
|
|
33
|
+
<%= tag.section class: "chats-split__pane", "aria-labelledby": title_id,
|
|
34
|
+
data: { conversation_id: conversation.id, path: conversation_path(conversation),
|
|
35
|
+
title: chats_page_title(conversation), read: !read_pending } do %>
|
|
36
|
+
<%= render "chats/conversations/thread_header",
|
|
37
|
+
conversation: conversation, participant: participant, counterpart: counterpart,
|
|
38
|
+
heading: :h2, title_id: title_id, back_path: conversations_path(**chats_inbox_filter) %>
|
|
39
|
+
|
|
40
|
+
<%= tag.div id: "chats_conversation_pane_#{conversation.id}_#{Chats.messager_key(chats_current_messager)}",
|
|
41
|
+
class: "chats-split__thread", data: { turbo_permanent: true } do %>
|
|
42
|
+
<%= render layout: "chats/conversations/thread",
|
|
43
|
+
locals: {
|
|
44
|
+
conversation: conversation,
|
|
45
|
+
messages: messages,
|
|
46
|
+
more: more,
|
|
47
|
+
first_unread_id: first_unread_id,
|
|
48
|
+
counterpart: counterpart,
|
|
49
|
+
pane: true,
|
|
50
|
+
read_pending: read_pending
|
|
51
|
+
} do %><% end %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% 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>
|