chats 0.4.2 → 0.5.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 +247 -0
- data/README.md +95 -8
- data/app/assets/stylesheets/chats.css +596 -0
- data/app/controllers/chats/conversations_controller.rb +71 -5
- data/app/helpers/chats/engine_helper.rb +75 -2
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1070 -0
- data/app/javascript/chats/thread_controller.js +72 -6
- data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
- data/app/views/chats/conversations/_group.html.erb +6 -2
- data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +172 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +2 -1
- data/app/views/chats/conversations/show.html.erb +11 -118
- data/app/views/chats/conversations/window.html.erb +26 -0
- data/app/views/chats/dock/_badge.html.erb +12 -0
- data/app/views/chats/dock/_dock.html.erb +130 -0
- data/app/views/chats/dock/_signal.html.erb +26 -0
- data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
- data/app/views/chats/dock/_window.html.erb +23 -0
- data/app/views/chats/dock/_window_header.html.erb +75 -0
- data/app/views/chats/dock/_window_shell.html.erb +17 -0
- data/app/views/chats/messages/_composer.html.erb +11 -1
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +17 -0
- data/config/locales/es.yml +17 -0
- data/config/routes.rb +5 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/gemfiles/rails_7.1.gemfile +2 -0
- data/gemfiles/rails_7.2.gemfile +2 -0
- data/gemfiles/rails_8.1.gemfile +2 -0
- data/lib/chats/broadcasts.rb +76 -10
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +19 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +18 -2
|
@@ -36,9 +36,17 @@ const TRIM_LEEWAY = 20
|
|
|
36
36
|
// already near the bottom (never yank them up
|
|
37
37
|
// mid-history-read)
|
|
38
38
|
// * mark-as-read debounce a POST to #read when foreign messages
|
|
39
|
-
// arrive while the
|
|
40
|
-
// advances our read horizon and broadcasts
|
|
41
|
-
// read-state to everyone
|
|
39
|
+
// arrive while the thread is on screen — that
|
|
40
|
+
// POST advances our read horizon and broadcasts
|
|
41
|
+
// fresh read-state to everyone. Only while the
|
|
42
|
+
// viewer can actually be reading: a visible tab,
|
|
43
|
+
// a thread with room to show itself (a minimized
|
|
44
|
+
// dock window has none), and — inside a dock
|
|
45
|
+
// window — one the viewer is engaged with (the
|
|
46
|
+
// dock's `data-chats-active` gate; a window that
|
|
47
|
+
// popped open by itself waits for a focus, a
|
|
48
|
+
// press or a scroll). A dock window is rendered
|
|
49
|
+
// unread (`readPending`) and reads the same way.
|
|
42
50
|
// * sent / seen receipts derive per-message ticks client-side from the
|
|
43
51
|
// broadcast read-state payload
|
|
44
52
|
// * day separators recalculate local-calendar markers after the
|
|
@@ -74,11 +82,16 @@ export default class extends Controller {
|
|
|
74
82
|
daySeparatorClass: { type: String, default: "chats-day-separator" },
|
|
75
83
|
typingSuffix: String,
|
|
76
84
|
copiedLabel: String,
|
|
77
|
-
group: Boolean
|
|
85
|
+
group: Boolean,
|
|
86
|
+
readPending: Boolean
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
connect() {
|
|
81
90
|
this.registerTypingStreamAction()
|
|
91
|
+
// The dock's engagement gate: the window frame around a dock thread.
|
|
92
|
+
// No gate (the thread page) means always engaged.
|
|
93
|
+
this.gate = this.element.closest("[data-chats-active]")
|
|
94
|
+
this.gate?.addEventListener("chats:activated", this.flushPendingRead)
|
|
82
95
|
|
|
83
96
|
// targetConnected fires for every server-rendered bubble right after
|
|
84
97
|
// connect; this flag keeps the initial flood from triggering N scrolls
|
|
@@ -90,6 +103,7 @@ export default class extends Controller {
|
|
|
90
103
|
this.renderReceipts()
|
|
91
104
|
this.renderDaySeparators()
|
|
92
105
|
this.renderMessageGroups()
|
|
106
|
+
if (this.readPendingValue) this.queueRead()
|
|
93
107
|
})
|
|
94
108
|
|
|
95
109
|
if (this.hasTypingTarget) {
|
|
@@ -111,6 +125,7 @@ export default class extends Controller {
|
|
|
111
125
|
}
|
|
112
126
|
this.element.addEventListener("click", this.suppressClickCapture, true)
|
|
113
127
|
this.watchStreamSource()
|
|
128
|
+
this.watchScreenPresence()
|
|
114
129
|
}
|
|
115
130
|
|
|
116
131
|
disconnect() {
|
|
@@ -120,6 +135,8 @@ export default class extends Controller {
|
|
|
120
135
|
document.removeEventListener("visibilitychange", this.visibilityChanged)
|
|
121
136
|
clearTimeout(this.readTimer)
|
|
122
137
|
this.sourceObserver?.disconnect()
|
|
138
|
+
this.presenceObserver?.disconnect()
|
|
139
|
+
this.gate?.removeEventListener("chats:activated", this.flushPendingRead)
|
|
123
140
|
this.element.removeEventListener("click", this.suppressClickCapture, true)
|
|
124
141
|
clearTimeout(this.pressTimer)
|
|
125
142
|
this.teardownPopup()
|
|
@@ -350,7 +367,7 @@ export default class extends Controller {
|
|
|
350
367
|
// --- Mark-as-read ------------------------------------------------------------
|
|
351
368
|
|
|
352
369
|
queueRead() {
|
|
353
|
-
if (
|
|
370
|
+
if (!this.canRead()) {
|
|
354
371
|
this.pendingRead = true
|
|
355
372
|
return
|
|
356
373
|
}
|
|
@@ -375,8 +392,54 @@ export default class extends Controller {
|
|
|
375
392
|
}
|
|
376
393
|
}
|
|
377
394
|
|
|
395
|
+
// Whether the viewer can be reading this thread right now.
|
|
396
|
+
canRead() {
|
|
397
|
+
return document.visibilityState === "visible" && this.engaged() && this.onScreen()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// The thread has room to show itself. Geometry alone, for any host that
|
|
401
|
+
// collapses a thread its own way; the dock's own state is engaged()'s job.
|
|
402
|
+
onScreen() {
|
|
403
|
+
return !this.hasScrollerTarget || this.scrollerTarget.clientHeight > 0
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Inside a dock window, what the dock says, explicitly: the window is
|
|
407
|
+
// neither minimized (data-chats-minimized) nor waiting for the viewer to
|
|
408
|
+
// turn to it (data-chats-active="false"). Geometry can't be trusted for
|
|
409
|
+
// the first: a folded window's scroller keeps whatever padding a host
|
|
410
|
+
// gives it. No gate (the thread page): always engaged.
|
|
411
|
+
engaged() {
|
|
412
|
+
if (!this.gate) return true
|
|
413
|
+
|
|
414
|
+
return this.gate.dataset.chatsActive !== "false" && !this.gate.hasAttribute("data-chats-minimized")
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// A read that waited for the thread to be seen happens the moment it is:
|
|
418
|
+
// a minimized window expanding gives its scroller a height, and the dock
|
|
419
|
+
// says when the viewer turns to a window. It lands on the newest message,
|
|
420
|
+
// which is what arrived.
|
|
421
|
+
flushPendingRead = () => {
|
|
422
|
+
if (!this.pendingRead || !this.canRead()) return
|
|
423
|
+
this.pendingRead = false
|
|
424
|
+
this.scrollToBottom()
|
|
425
|
+
this.queueRead()
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
watchScreenPresence() {
|
|
429
|
+
if (!this.hasScrollerTarget || typeof ResizeObserver === "undefined") return
|
|
430
|
+
|
|
431
|
+
this.presenceObserver = new ResizeObserver(this.flushPendingRead)
|
|
432
|
+
this.presenceObserver.observe(this.scrollerTarget)
|
|
433
|
+
}
|
|
434
|
+
|
|
378
435
|
postRead() {
|
|
379
436
|
if (!this.readUrlValue) return
|
|
437
|
+
// Asked again when the timer fires: the window may have been minimized
|
|
438
|
+
// (or the tab hidden) in the 700ms since the message arrived.
|
|
439
|
+
if (!this.canRead()) {
|
|
440
|
+
this.pendingRead = true
|
|
441
|
+
return
|
|
442
|
+
}
|
|
380
443
|
fetch(this.readUrlValue, {
|
|
381
444
|
method: "POST",
|
|
382
445
|
headers: { "X-CSRF-Token": csrfToken(), Accept: "application/json" }
|
|
@@ -822,8 +885,11 @@ export default class extends Controller {
|
|
|
822
885
|
const messageId = bubble.id.split("_").pop()
|
|
823
886
|
|
|
824
887
|
this.closePopup()
|
|
888
|
+
// `thread`: a page can hold several threads (the page's and the dock's
|
|
889
|
+
// windows), each with its own composer listening on window — the
|
|
890
|
+
// composer inside THIS thread is the one that edits.
|
|
825
891
|
window.dispatchEvent(new CustomEvent("chats:edit-message", {
|
|
826
|
-
detail: { id: messageId, body: body }
|
|
892
|
+
detail: { id: messageId, body: body, thread: this.element }
|
|
827
893
|
}))
|
|
828
894
|
}
|
|
829
895
|
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
<%# One inbox row. Rendered per-viewer (this page only updates via full
|
|
2
2
|
refreshes, never shared broadcasts, so viewer-specific state is fine here —
|
|
3
|
-
unlike message bubbles, which must stay viewer-agnostic).
|
|
3
|
+
unlike message bubbles, which must stay viewer-agnostic).
|
|
4
|
+
|
|
5
|
+
`dock: true` (the messaging dock's recent list) keeps the link a link to
|
|
6
|
+
the thread and marks it as a dock opener: a plain click opens the
|
|
7
|
+
conversation's floating window instead. A muted conversation carries
|
|
8
|
+
the bell-slash. %>
|
|
4
9
|
<li class="chats-row <%= "chats-row--unread" if unread_count.positive? %>">
|
|
5
|
-
<%= link_to conversation_path(conversation), class: "chats-row__link"
|
|
10
|
+
<%= link_to conversation_path(conversation), class: "chats-row__link",
|
|
11
|
+
data: (chats_dock_opener(conversation) if local_assigns[:dock]) do %>
|
|
6
12
|
<%= chats_conversation_avatar(conversation, viewer) %>
|
|
7
13
|
|
|
8
14
|
<span class="chats-row__body">
|
|
@@ -13,6 +19,11 @@
|
|
|
13
19
|
<span class="chats-row__name">
|
|
14
20
|
<span class="chats-row__title"><%= conversation.title_for(viewer) %></span>
|
|
15
21
|
<%= chats_verified_badge(conversation.counterpart_for(viewer)) %>
|
|
22
|
+
<% if chats_muted?(conversation, viewer) %>
|
|
23
|
+
<svg class="chats-row__muted" role="img" aria-label="<%= t("chats.dock.muted") %>" viewBox="0 0 24 24"
|
|
24
|
+
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
25
|
+
focusable="false"><title><%= t("chats.dock.muted") %></title><path d="M13.7 21a2 2 0 0 1-3.4 0M18.6 13A17.9 17.9 0 0 1 18 8M6.3 6.3A5.9 5.9 0 0 0 6 8c0 7-3 9-3 9h14M18 8a6 6 0 0 0-9.3-5M3 3l18 18"/></svg>
|
|
26
|
+
<% end %>
|
|
16
27
|
</span>
|
|
17
28
|
<time class="chats-row__time" datetime="<%= conversation.last_message_at&.iso8601 %>">
|
|
18
29
|
<%= chats_timestamp(conversation.last_message_at) %>
|
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
|
|
6
6
|
A stack of one links straight to that thread (and the thread offers a
|
|
7
7
|
"see all" link back here); a deeper stack opens the stack list, which is
|
|
8
|
-
either the host's `group_path:` or chats' own filtered inbox.
|
|
8
|
+
either the host's `group_path:` or chats' own filtered inbox.
|
|
9
|
+
|
|
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. %>
|
|
9
12
|
<li class="chats-row chats-row--group <%= "chats-row--unread" if group.unread? %>"
|
|
10
13
|
id="<%= group.dom_id %>">
|
|
11
|
-
<%= link_to chats_group_path(group), class: "chats-row__link"
|
|
14
|
+
<%= link_to chats_group_path(group), class: "chats-row__link",
|
|
15
|
+
data: (chats_dock_opener(group.conversation) if local_assigns[:dock] && group.single?) do %>
|
|
12
16
|
<%= chats_messager_avatar(group.messager) %>
|
|
13
17
|
|
|
14
18
|
<span class="chats-row__body">
|
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
composer — the host's composer_actions leave with it.
|
|
6
6
|
|
|
7
7
|
The `locked_composer` slot replaces the BODY (a reopen button, a link to
|
|
8
|
-
the closed ticket…) while keeping the id contract intact.
|
|
8
|
+
the closed ticket…) while keeping the id contract intact.
|
|
9
|
+
|
|
10
|
+
`notice:` replaces the subject's reason: the thread renders this same
|
|
11
|
+
box, with "You can't message this person.", for a direct thread the
|
|
12
|
+
host's policy no longer allows (or whose other account is gone). %>
|
|
13
|
+
<% notice = local_assigns[:notice] || conversation.locked_notice %>
|
|
9
14
|
<div id="<%= dom_id(conversation, :composer) %>" class="chats-composer chats-composer--locked">
|
|
10
15
|
<% if chats_slot?(:locked_composer) %>
|
|
11
|
-
<%= chats_slot :locked_composer, conversation: conversation, notice:
|
|
16
|
+
<%= chats_slot :locked_composer, conversation: conversation, notice: notice %>
|
|
12
17
|
<% else %>
|
|
13
|
-
<p class="chats-composer__locked-notice"><%=
|
|
18
|
+
<p class="chats-composer__locked-notice"><%= notice %></p>
|
|
14
19
|
<% end %>
|
|
15
20
|
</div>
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<%# The thread body: everything live about one open conversation, shared by
|
|
2
|
+
the thread PAGE (show.html.erb) and a floating dock WINDOW
|
|
3
|
+
(window.html.erb). Rendered as a partial LAYOUT — `yield` is the header,
|
|
4
|
+
which is the one thing the two differ in: the page keeps its full header
|
|
5
|
+
(back link, ⋯ menu), the window its compact one (minimize, close, open
|
|
6
|
+
full page).
|
|
7
|
+
|
|
8
|
+
One conversation stream powers everything here: message
|
|
9
|
+
append/replace/remove, the read-state payload, and typing pings. Bubbles
|
|
10
|
+
arrive VIEWER-AGNOSTIC from shared broadcasts; the chats--thread Stimulus
|
|
11
|
+
controller aligns own-vs-other by comparing each bubble's sender key
|
|
12
|
+
against its `me` value (that's what makes one broadcast render work for
|
|
13
|
+
every subscriber).
|
|
14
|
+
|
|
15
|
+
The DOM ids (messages, composer, composer_errors, read_state, typing) are
|
|
16
|
+
the targets the streams address, so a conversation is rendered at most
|
|
17
|
+
ONCE per page: the dock never opens a window for a conversation the page
|
|
18
|
+
already shows.
|
|
19
|
+
|
|
20
|
+
Locals:
|
|
21
|
+
conversation the Chats::Conversation
|
|
22
|
+
messages the latest page, oldest first
|
|
23
|
+
more whether older pages exist (the lazy frame chain)
|
|
24
|
+
first_unread_id where the «new messages» line goes, or nil
|
|
25
|
+
counterpart the other messager of a direct thread, or nil
|
|
26
|
+
window true in a dock window: rooted at .chats-thread--window
|
|
27
|
+
(never .chats, which is the full-page shell), and the
|
|
28
|
+
server did NOT read this render — the controller
|
|
29
|
+
posts #read once the window is on screen and the
|
|
30
|
+
viewer is engaged with it (`readPending`).
|
|
31
|
+
|
|
32
|
+
The page's root carries data-chats-dock-hide: the messaging dock hides
|
|
33
|
+
itself on any page that has it. %>
|
|
34
|
+
<% window = local_assigns.fetch(:window, false) %>
|
|
35
|
+
<% viewer = chats_current_messager %>
|
|
36
|
+
<%# A direct thread nobody may write in any more says so up front, in the
|
|
37
|
+
composer's place — the host's policy turned false, or the other account
|
|
38
|
+
is gone — instead of letting a send fail. Same id as the composer, so
|
|
39
|
+
the live lock swap still finds it. %>
|
|
40
|
+
<% refused = !conversation.locked? && conversation.direct? &&
|
|
41
|
+
(counterpart.nil? || !Chats.can_message?(viewer, counterpart)) %>
|
|
42
|
+
<%= tag.div class: (window ? "chats-thread chats-thread--window" : "chats chats-thread"), data: {
|
|
43
|
+
controller: "chats--thread",
|
|
44
|
+
"chats-dock-hide": (true unless window),
|
|
45
|
+
"chats--thread-me-value": Chats.messager_key(viewer),
|
|
46
|
+
"chats--thread-read-url-value": read_conversation_path(conversation),
|
|
47
|
+
"chats--thread-read-pending-value": window,
|
|
48
|
+
"chats--thread-sent-label-value": t("chats.thread.sent"),
|
|
49
|
+
"chats--thread-seen-label-value": Chats.config.read_receipts ? t("chats.thread.seen") : "",
|
|
50
|
+
"chats--thread-today-label-value": t("chats.thread.today"),
|
|
51
|
+
"chats--thread-yesterday-label-value": t("chats.thread.yesterday"),
|
|
52
|
+
"chats--thread-typing-suffix-value": t("chats.thread.typing_suffix"),
|
|
53
|
+
"chats--thread-group-value": conversation.group?,
|
|
54
|
+
"chats--thread-refresh-url-value": refresh_conversation_path(conversation),
|
|
55
|
+
"chats--thread-thread-url-value": conversation_path(conversation),
|
|
56
|
+
"chats--thread-copied-label-value": t("chats.message.copied"),
|
|
57
|
+
# A plain data attribute (not a Stimulus value): host CSS/JS keys its
|
|
58
|
+
# own block/report affordances off it. False for a messager declared
|
|
59
|
+
# `acts_as_messager blockable: false`.
|
|
60
|
+
"chats-blockable": chats_blockable?(counterpart),
|
|
61
|
+
action: "pointerdown->chats--thread#pressStart pointermove->chats--thread#pressMove " \
|
|
62
|
+
"pointerup->chats--thread#pressEnd pointercancel->chats--thread#pressCancel " \
|
|
63
|
+
"contextmenu->chats--thread#contextMenu"
|
|
64
|
+
} do %>
|
|
65
|
+
|
|
66
|
+
<%= turbo_stream_from conversation %>
|
|
67
|
+
|
|
68
|
+
<%= yield %>
|
|
69
|
+
|
|
70
|
+
<%# tabindex: the arrow keys scroll the history (WCAG 2.1.1). %>
|
|
71
|
+
<div class="chats-thread__scroll" data-chats--thread-target="scroller" tabindex="0">
|
|
72
|
+
<% if window && messages.empty? %>
|
|
73
|
+
<%# A window opened from a Message button, before anybody wrote: who
|
|
74
|
+
you're about to talk to. Gone with the first bubble (chats.css). %>
|
|
75
|
+
<div class="chats-window__hello">
|
|
76
|
+
<% if counterpart %>
|
|
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>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
|
|
119
|
+
pressed bubble's clone morphs to the center, the reactions pill sits
|
|
120
|
+
above it and the contextual menu below. Slots are filled by cloning
|
|
121
|
+
the bubble's <template data-chats-message-menu>. %>
|
|
122
|
+
<div class="chats-popup"
|
|
123
|
+
role="dialog"
|
|
124
|
+
aria-modal="true"
|
|
125
|
+
data-chats--thread-target="popup"
|
|
126
|
+
hidden>
|
|
127
|
+
<div class="chats-popup__backdrop" data-action="click->chats--thread#closePopup"></div>
|
|
128
|
+
<div class="chats-popup__stack">
|
|
129
|
+
<div class="chats-popup__slot chats-popup__slot--reactions"
|
|
130
|
+
data-chats--thread-target="popupReactions"
|
|
131
|
+
data-action="click->chats--thread#popupMenuClicked"></div>
|
|
132
|
+
<div class="chats-popup__slot chats-popup__slot--bubble" data-chats--thread-target="popupBubble"></div>
|
|
133
|
+
<div class="chats-popup__slot chats-popup__slot--menu"
|
|
134
|
+
data-chats--thread-target="popupMenu"
|
|
135
|
+
data-action="click->chats--thread#popupMenuClicked"></div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div class="chats-attachment-preview"
|
|
140
|
+
role="dialog"
|
|
141
|
+
aria-modal="true"
|
|
142
|
+
tabindex="-1"
|
|
143
|
+
data-chats--thread-target="attachmentDialog"
|
|
144
|
+
data-action="keydown.esc->chats--thread#closeAttachment"
|
|
145
|
+
hidden>
|
|
146
|
+
<div class="chats-attachment-preview__surface"
|
|
147
|
+
data-action="click->chats--thread#closeAttachmentFromBackdrop">
|
|
148
|
+
<button type="button"
|
|
149
|
+
class="chats-attachment-preview__close"
|
|
150
|
+
aria-label="<%= t("chats.message.close_attachment") %>"
|
|
151
|
+
data-action="chats--thread#closeAttachment">×</button>
|
|
152
|
+
<figure class="chats-attachment-preview__figure">
|
|
153
|
+
<img class="chats-attachment-preview__image"
|
|
154
|
+
alt=""
|
|
155
|
+
data-chats--thread-target="attachmentImage">
|
|
156
|
+
</figure>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<%# A locked conversation (its SUBJECT says so — Chats::ChatSubject#
|
|
161
|
+
chat_locked?) keeps its whole history readable and swaps the composer
|
|
162
|
+
for the reason it's closed. Never a hidden screen: gate the action,
|
|
163
|
+
explain it in place. %>
|
|
164
|
+
<% if conversation.locked? %>
|
|
165
|
+
<%= render "chats/conversations/locked_composer", conversation: conversation %>
|
|
166
|
+
<% elsif refused %>
|
|
167
|
+
<%= render "chats/conversations/locked_composer", conversation: conversation,
|
|
168
|
+
notice: t("chats.flashes.blocked") %>
|
|
169
|
+
<% else %>
|
|
170
|
+
<%= render "chats/messages/composer", conversation: conversation %>
|
|
171
|
+
<% end %>
|
|
172
|
+
<% end %>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<%# The dock's recent list (GET /dock): the first rows of the viewer's inbox,
|
|
2
|
+
rendered into the dock's `chats_dock_list` frame with the inbox's own row
|
|
3
|
+
partials. `dock: true` makes each row open a floating window instead of
|
|
4
|
+
navigating — the link stays a real link to the thread, so a modified click
|
|
5
|
+
(new tab) or a page without the dock's JavaScript still gets there. The
|
|
6
|
+
inbox answers a search from the dock's form with this same template.
|
|
7
|
+
|
|
8
|
+
`target: "_top"`: anything else inside the frame (a stack's link, a row
|
|
9
|
+
from an inbox ejected before 0.5.0) navigates the page, not the frame. %>
|
|
10
|
+
<%= turbo_frame_tag "chats_dock_list", target: "_top" do %>
|
|
11
|
+
<% if @rows.any? %>
|
|
12
|
+
<ul class="chats-inbox__list chats-dock__list">
|
|
13
|
+
<% @rows.each do |row| %>
|
|
14
|
+
<% if row.is_a?(Chats::InboxGroup) %>
|
|
15
|
+
<%= render "chats/conversations/group", group: row, viewer: chats_current_messager, dock: true %>
|
|
16
|
+
<% else %>
|
|
17
|
+
<%= render "chats/conversations/conversation_row",
|
|
18
|
+
conversation: row,
|
|
19
|
+
viewer: chats_current_messager,
|
|
20
|
+
unread_count: @unread_counts.fetch(row.id, 0),
|
|
21
|
+
dock: true %>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</ul>
|
|
25
|
+
<% elsif @inbox.query %>
|
|
26
|
+
<div class="chats-empty">
|
|
27
|
+
<p class="chats-empty__title"><%= t("chats.inbox.no_results_title") %></p>
|
|
28
|
+
<p class="chats-empty__hint"><%= t("chats.inbox.no_results_hint", query: @inbox.query) %></p>
|
|
29
|
+
</div>
|
|
30
|
+
<% else %>
|
|
31
|
+
<%# The inbox's own empty state, the host's inbox_empty slot included:
|
|
32
|
+
the dock is how people find messaging, so it stays. %>
|
|
33
|
+
<div class="chats-empty">
|
|
34
|
+
<span class="chats-empty__icon" aria-hidden="true">💬</span>
|
|
35
|
+
<p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
|
|
36
|
+
<p class="chats-empty__hint"><%= t("chats.inbox.empty_hint") %></p>
|
|
37
|
+
<%= chats_slot :inbox_empty, viewer: chats_current_messager, inbox: @inbox %>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% end %>
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
<%= turbo_stream_from chats_current_messager, :chats_inbox %>
|
|
36
36
|
</div>
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
<%# data-chats-dock-hide: the messaging dock hides itself on this page. %>
|
|
39
|
+
<div class="chats chats-inbox" data-chats-dock-hide>
|
|
39
40
|
<header class="chats-inbox__header">
|
|
40
41
|
<h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
|
|
41
42
|
</header>
|
|
@@ -1,35 +1,16 @@
|
|
|
1
|
-
<%# The thread
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
Stimulus controller aligns own-vs-other by comparing each bubble's
|
|
5
|
-
sender key against its `me` value (that's what makes one broadcast render
|
|
6
|
-
work for every subscriber). %>
|
|
1
|
+
<%# The thread page: its own header around the shared thread body
|
|
2
|
+
(chats/conversations/_thread, which a floating dock window renders too —
|
|
3
|
+
see that partial for how the live thread works). %>
|
|
7
4
|
<%= chats_styles %>
|
|
8
5
|
|
|
9
|
-
<%=
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"chats--thread-typing-suffix-value": t("chats.thread.typing_suffix"),
|
|
18
|
-
"chats--thread-group-value": @conversation.group?,
|
|
19
|
-
"chats--thread-refresh-url-value": refresh_conversation_path(@conversation),
|
|
20
|
-
"chats--thread-thread-url-value": conversation_path(@conversation),
|
|
21
|
-
"chats--thread-copied-label-value": t("chats.message.copied"),
|
|
22
|
-
# A plain data attribute (not a Stimulus value): host CSS/JS keys its
|
|
23
|
-
# own block/report affordances off it. False for a messager declared
|
|
24
|
-
# `acts_as_messager blockable: false`.
|
|
25
|
-
"chats-blockable": chats_blockable?(chats_counterpart),
|
|
26
|
-
action: "pointerdown->chats--thread#pressStart pointermove->chats--thread#pressMove " \
|
|
27
|
-
"pointerup->chats--thread#pressEnd pointercancel->chats--thread#pressCancel " \
|
|
28
|
-
"contextmenu->chats--thread#contextMenu"
|
|
29
|
-
} do %>
|
|
30
|
-
|
|
31
|
-
<%= turbo_stream_from @conversation %>
|
|
32
|
-
|
|
6
|
+
<%= render layout: "chats/conversations/thread",
|
|
7
|
+
locals: {
|
|
8
|
+
conversation: @conversation,
|
|
9
|
+
messages: @messages,
|
|
10
|
+
more: @more_messages,
|
|
11
|
+
first_unread_id: @first_unread_id,
|
|
12
|
+
counterpart: chats_counterpart
|
|
13
|
+
} do %>
|
|
33
14
|
<header class="chats-thread__header">
|
|
34
15
|
<%= link_to root_path, class: "chats-thread__back", "aria-label": t("chats.thread.back") do %>
|
|
35
16
|
<span aria-hidden="true">‹</span>
|
|
@@ -91,92 +72,4 @@
|
|
|
91
72
|
</div>
|
|
92
73
|
</details>
|
|
93
74
|
</header>
|
|
94
|
-
|
|
95
|
-
<div class="chats-thread__scroll" data-chats--thread-target="scroller">
|
|
96
|
-
<% if @more_messages && @messages.any? %>
|
|
97
|
-
<%# Lazy frame chain for infinite scroll-up: when this frame becomes
|
|
98
|
-
visible, Turbo fetches the previous keyset page (?before=oldest id),
|
|
99
|
-
whose response contains a frame with this SAME id — older messages
|
|
100
|
-
plus, recursively, the next frame. See ConversationsController#show. %>
|
|
101
|
-
<%= turbo_frame_tag "chats_page_#{@messages.first.id}",
|
|
102
|
-
src: conversation_path(@conversation, before: @messages.first.id),
|
|
103
|
-
loading: :lazy do %>
|
|
104
|
-
<div class="chats-loader"><%= t("chats.thread.loading") %></div>
|
|
105
|
-
<% end %>
|
|
106
|
-
<% end %>
|
|
107
|
-
|
|
108
|
-
<div id="<%= dom_id(@conversation, :messages) %>" class="chats-thread__messages">
|
|
109
|
-
<%# Explicit loop (not a collection render) so the «new messages»
|
|
110
|
-
divider can sit BEFORE the first unread bubble (WhatsApp's "the
|
|
111
|
-
catch-up starts here" line). Server-rendered on the initial page
|
|
112
|
-
only; it scrolls away naturally as the thread lives on. %>
|
|
113
|
-
<% @messages.each do |message| %>
|
|
114
|
-
<% if message.id == @first_unread_id %>
|
|
115
|
-
<div class="chats-thread__unread-line" role="separator">
|
|
116
|
-
<span><%= t("chats.thread.new_messages") %></span>
|
|
117
|
-
</div>
|
|
118
|
-
<% end %>
|
|
119
|
-
<%= render "chats/messages/message", message: message %>
|
|
120
|
-
<% end %>
|
|
121
|
-
</div>
|
|
122
|
-
|
|
123
|
-
<%= render "chats/conversations/read_state", conversation: @conversation %>
|
|
124
|
-
|
|
125
|
-
<div id="<%= dom_id(@conversation, :typing) %>"
|
|
126
|
-
class="chats-typing"
|
|
127
|
-
data-chats--thread-target="typing"
|
|
128
|
-
hidden></div>
|
|
129
|
-
</div>
|
|
130
|
-
|
|
131
|
-
<%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
|
|
132
|
-
pressed bubble's clone morphs to the center, the reactions pill sits
|
|
133
|
-
above it and the contextual menu below. Slots are filled by cloning
|
|
134
|
-
the bubble's <template data-chats-message-menu>. %>
|
|
135
|
-
<div class="chats-popup"
|
|
136
|
-
role="dialog"
|
|
137
|
-
aria-modal="true"
|
|
138
|
-
data-chats--thread-target="popup"
|
|
139
|
-
hidden>
|
|
140
|
-
<div class="chats-popup__backdrop" data-action="click->chats--thread#closePopup"></div>
|
|
141
|
-
<div class="chats-popup__stack">
|
|
142
|
-
<div class="chats-popup__slot chats-popup__slot--reactions"
|
|
143
|
-
data-chats--thread-target="popupReactions"
|
|
144
|
-
data-action="click->chats--thread#popupMenuClicked"></div>
|
|
145
|
-
<div class="chats-popup__slot chats-popup__slot--bubble" data-chats--thread-target="popupBubble"></div>
|
|
146
|
-
<div class="chats-popup__slot chats-popup__slot--menu"
|
|
147
|
-
data-chats--thread-target="popupMenu"
|
|
148
|
-
data-action="click->chats--thread#popupMenuClicked"></div>
|
|
149
|
-
</div>
|
|
150
|
-
</div>
|
|
151
|
-
|
|
152
|
-
<div class="chats-attachment-preview"
|
|
153
|
-
role="dialog"
|
|
154
|
-
aria-modal="true"
|
|
155
|
-
tabindex="-1"
|
|
156
|
-
data-chats--thread-target="attachmentDialog"
|
|
157
|
-
data-action="keydown.esc->chats--thread#closeAttachment"
|
|
158
|
-
hidden>
|
|
159
|
-
<div class="chats-attachment-preview__surface"
|
|
160
|
-
data-action="click->chats--thread#closeAttachmentFromBackdrop">
|
|
161
|
-
<button type="button"
|
|
162
|
-
class="chats-attachment-preview__close"
|
|
163
|
-
aria-label="<%= t("chats.message.close_attachment") %>"
|
|
164
|
-
data-action="chats--thread#closeAttachment">×</button>
|
|
165
|
-
<figure class="chats-attachment-preview__figure">
|
|
166
|
-
<img class="chats-attachment-preview__image"
|
|
167
|
-
alt=""
|
|
168
|
-
data-chats--thread-target="attachmentImage">
|
|
169
|
-
</figure>
|
|
170
|
-
</div>
|
|
171
|
-
</div>
|
|
172
|
-
|
|
173
|
-
<%# A locked conversation (its SUBJECT says so — Chats::ChatSubject#
|
|
174
|
-
chat_locked?) keeps its whole history readable and swaps the composer
|
|
175
|
-
for the reason it's closed. Never a hidden screen: gate the action,
|
|
176
|
-
explain it in place. %>
|
|
177
|
-
<% if @conversation.locked? %>
|
|
178
|
-
<%= render "chats/conversations/locked_composer", conversation: @conversation %>
|
|
179
|
-
<% else %>
|
|
180
|
-
<%= render "chats/messages/composer", conversation: @conversation %>
|
|
181
|
-
<% end %>
|
|
182
75
|
<% end %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%# A floating dock window's content (GET /:id?window=1 from inside the
|
|
2
|
+
window's turbo frame — see chats/dock/_window for the frame and
|
|
3
|
+
ConversationsController#show): the SAME thread body the page renders,
|
|
4
|
+
rooted at .chats-thread--window, under the window's compact header.
|
|
5
|
+
|
|
6
|
+
The header's chats--dock actions reach the dock's controller because the
|
|
7
|
+
window lives inside the dock element. The title is a button: it
|
|
8
|
+
minimizes and expands the window (LinkedIn's header click), and says
|
|
9
|
+
which way with aria-expanded. %>
|
|
10
|
+
<% title = @conversation.title_for(chats_current_messager) %>
|
|
11
|
+
<%= turbo_frame_tag dom_id(@conversation, :window) do %>
|
|
12
|
+
<section class="chats-window__card" role="region" aria-label="<%= t("chats.dock.window_label", name: title) %>">
|
|
13
|
+
<%= render layout: "chats/conversations/thread",
|
|
14
|
+
locals: {
|
|
15
|
+
conversation: @conversation,
|
|
16
|
+
messages: @messages,
|
|
17
|
+
more: @more_messages,
|
|
18
|
+
first_unread_id: @first_unread_id,
|
|
19
|
+
counterpart: chats_counterpart,
|
|
20
|
+
window: true
|
|
21
|
+
} do %>
|
|
22
|
+
<%= render "chats/dock/window_header", conversation: @conversation, title: title,
|
|
23
|
+
counterpart: chats_counterpart %>
|
|
24
|
+
<% end %>
|
|
25
|
+
</section>
|
|
26
|
+
<% end %>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%# The dock's unread badge: the same number as the nav badge
|
|
2
|
+
(`unread_chats_count`), replaced on the viewer's dock stream with every
|
|
3
|
+
other [data-chats-unread-badge] inside the dock. No id — a page may hold
|
|
4
|
+
the nav badge too, and ids must stay unique. Not a live region: it would
|
|
5
|
+
chatter on every read; new messages are announced by the dock instead.
|
|
6
|
+
Hidden at zero, number kept. %>
|
|
7
|
+
<%= tag.span class: "chats-badge chats-dock__badge",
|
|
8
|
+
hidden: !count.positive?,
|
|
9
|
+
data: { chats_unread_badge: true, "chats--dock-target": "badge", count: count } do %>
|
|
10
|
+
<span aria-hidden="true"><%= count > 99 ? "99+" : count %></span>
|
|
11
|
+
<span class="chats-visually-hidden"><%= count %> <%= t("chats.dock.unread_suffix") %></span>
|
|
12
|
+
<% end %>
|