chats 0.4.2 → 0.5.1
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 +478 -0
- data/README.md +150 -10
- data/app/assets/stylesheets/chats.css +599 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +273 -55
- data/app/helpers/chats/engine_helper.rb +102 -6
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1106 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/thread_controller.js +317 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- 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/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +133 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +24 -10
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- 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 +138 -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/messages/_message.html.erb +8 -6
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +17 -0
- data/config/locales/es.yml +17 -0
- data/config/routes.rb +8 -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 +178 -17
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +57 -0
- data/lib/chats/models/message.rb +98 -9
- data/lib/chats/models/reaction.rb +13 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +23 -2
|
@@ -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">
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<%# The history inside a thread's scroller: the latest page of messages, the
|
|
2
|
+
lazy frame chain above it, the read state and the typing line. The thread
|
|
3
|
+
(chats/conversations/_thread) renders it once; the stale-thread catch-up
|
|
4
|
+
re-renders it in place when the thread fell more than a page behind, and
|
|
5
|
+
leaves everything else (the header, the composer and its draft) alone.
|
|
6
|
+
|
|
7
|
+
Locals: conversation, messages (oldest first), more, first_unread_id,
|
|
8
|
+
counterpart, window — as in chats/conversations/_thread. %>
|
|
9
|
+
<% viewer = chats_current_messager %>
|
|
10
|
+
<% if window && messages.empty? %>
|
|
11
|
+
<%# A window opened from a Message button, before anybody wrote: who
|
|
12
|
+
you're about to talk to. Gone with the first bubble (chats.css). %>
|
|
13
|
+
<div class="chats-window__hello">
|
|
14
|
+
<% if counterpart %>
|
|
15
|
+
<%= chats_messager_avatar(counterpart) %>
|
|
16
|
+
<strong><%= Chats.display_name_for(counterpart) %></strong>
|
|
17
|
+
<% end %>
|
|
18
|
+
<%= t("chats.dock.hello", name: conversation.title_for(viewer)) %>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% if more && messages.any? %>
|
|
22
|
+
<%# Lazy frame chain for infinite scroll-up: when this frame becomes
|
|
23
|
+
visible, Turbo fetches the previous keyset page (?before=oldest id),
|
|
24
|
+
whose response contains a frame with this SAME id — older messages
|
|
25
|
+
plus, recursively, the next frame. See ConversationsController#show. %>
|
|
26
|
+
<%= turbo_frame_tag "chats_page_#{messages.first.id}",
|
|
27
|
+
src: conversation_path(conversation, before: messages.first.id),
|
|
28
|
+
loading: :lazy do %>
|
|
29
|
+
<div class="chats-loader"><%= t("chats.thread.loading") %></div>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% end %>
|
|
32
|
+
|
|
33
|
+
<div id="<%= dom_id(conversation, :messages) %>" class="chats-thread__messages">
|
|
34
|
+
<%# Explicit loop (not a collection render) so the «new messages»
|
|
35
|
+
divider can sit BEFORE the first unread bubble (WhatsApp's "the
|
|
36
|
+
catch-up starts here" line). Rendered with the history only (the
|
|
37
|
+
thread's first render, or a catch-up that re-renders it); it scrolls
|
|
38
|
+
away naturally as the thread lives on. %>
|
|
39
|
+
<% messages.each do |message| %>
|
|
40
|
+
<% if message.id == first_unread_id %>
|
|
41
|
+
<div class="chats-thread__unread-line" role="separator">
|
|
42
|
+
<span><%= t("chats.thread.new_messages") %></span>
|
|
43
|
+
</div>
|
|
44
|
+
<% end %>
|
|
45
|
+
<%= render "chats/messages/message", message: message %>
|
|
46
|
+
<% end %>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<%= render "chats/conversations/read_state", conversation: conversation %>
|
|
50
|
+
|
|
51
|
+
<div id="<%= dom_id(conversation, :typing) %>"
|
|
52
|
+
class="chats-typing"
|
|
53
|
+
data-chats--thread-target="typing"
|
|
54
|
+
hidden></div>
|
|
@@ -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,133 @@
|
|
|
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). The id is
|
|
71
|
+
what the stale-thread catch-up re-renders when the thread fell more
|
|
72
|
+
than a page behind (ConversationsController#refresh). %>
|
|
73
|
+
<div id="<%= dom_id(conversation, :history) %>" class="chats-thread__scroll" data-chats--thread-target="scroller" tabindex="0">
|
|
74
|
+
<%= render "chats/conversations/history", conversation: conversation, messages: messages, more: more,
|
|
75
|
+
first_unread_id: first_unread_id, counterpart: counterpart,
|
|
76
|
+
window: window %>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
|
|
80
|
+
pressed bubble's clone morphs to the center, the reactions pill sits
|
|
81
|
+
above it and the contextual menu below. Slots are filled by cloning
|
|
82
|
+
the bubble's <template data-chats-message-menu>. %>
|
|
83
|
+
<div class="chats-popup"
|
|
84
|
+
role="dialog"
|
|
85
|
+
aria-modal="true"
|
|
86
|
+
data-chats--thread-target="popup"
|
|
87
|
+
hidden>
|
|
88
|
+
<div class="chats-popup__backdrop" data-action="click->chats--thread#closePopup"></div>
|
|
89
|
+
<div class="chats-popup__stack">
|
|
90
|
+
<div class="chats-popup__slot chats-popup__slot--reactions"
|
|
91
|
+
data-chats--thread-target="popupReactions"
|
|
92
|
+
data-action="click->chats--thread#popupMenuClicked"></div>
|
|
93
|
+
<div class="chats-popup__slot chats-popup__slot--bubble" data-chats--thread-target="popupBubble"></div>
|
|
94
|
+
<div class="chats-popup__slot chats-popup__slot--menu"
|
|
95
|
+
data-chats--thread-target="popupMenu"
|
|
96
|
+
data-action="click->chats--thread#popupMenuClicked"></div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div class="chats-attachment-preview"
|
|
101
|
+
role="dialog"
|
|
102
|
+
aria-modal="true"
|
|
103
|
+
tabindex="-1"
|
|
104
|
+
data-chats--thread-target="attachmentDialog"
|
|
105
|
+
data-action="keydown.esc->chats--thread#closeAttachment"
|
|
106
|
+
hidden>
|
|
107
|
+
<div class="chats-attachment-preview__surface"
|
|
108
|
+
data-action="click->chats--thread#closeAttachmentFromBackdrop">
|
|
109
|
+
<button type="button"
|
|
110
|
+
class="chats-attachment-preview__close"
|
|
111
|
+
aria-label="<%= t("chats.message.close_attachment") %>"
|
|
112
|
+
data-action="chats--thread#closeAttachment">×</button>
|
|
113
|
+
<figure class="chats-attachment-preview__figure">
|
|
114
|
+
<img class="chats-attachment-preview__image"
|
|
115
|
+
alt=""
|
|
116
|
+
data-chats--thread-target="attachmentImage">
|
|
117
|
+
</figure>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<%# A locked conversation (its SUBJECT says so — Chats::ChatSubject#
|
|
122
|
+
chat_locked?) keeps its whole history readable and swaps the composer
|
|
123
|
+
for the reason it's closed. Never a hidden screen: gate the action,
|
|
124
|
+
explain it in place. %>
|
|
125
|
+
<% if conversation.locked? %>
|
|
126
|
+
<%= render "chats/conversations/locked_composer", conversation: conversation %>
|
|
127
|
+
<% elsif refused %>
|
|
128
|
+
<%= render "chats/conversations/locked_composer", conversation: conversation,
|
|
129
|
+
notice: t("chats.flashes.blocked") %>
|
|
130
|
+
<% else %>
|
|
131
|
+
<%= render "chats/messages/composer", conversation: conversation %>
|
|
132
|
+
<% end %>
|
|
133
|
+
<% end %>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# Every unread badge on the page (ConversationsController#badge), as the
|
|
2
|
+
badge streams would have sent them: the nav badge
|
|
3
|
+
(Chats::Broadcasts.update_badge_of) and the dock's (chats/dock/_update).
|
|
4
|
+
A page without one of them simply has nothing to replace. %>
|
|
5
|
+
<%= turbo_stream.replace "chats_unread_badge", partial: "chats/shared/unread_badge", locals: { count: count } %>
|
|
6
|
+
<%= turbo_stream.replace_all ".chats-dock [data-chats-unread-badge]", partial: "chats/dock/badge", locals: { count: count } %>
|
|
@@ -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 %>
|
|
@@ -10,18 +10,22 @@
|
|
|
10
10
|
The chats--refresh-inbox wrapper heals a MISSED refresh broadcast: Action
|
|
11
11
|
Cable has no replay, so a broadcast sent while the socket was down (tab
|
|
12
12
|
backgrounded, network blip) would leave the inbox stale until the user
|
|
13
|
-
navigated. The controller re-runs the same page refresh
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
navigated. The controller re-runs the same page refresh when the stream
|
|
14
|
+
reconnects, when the page comes back after 10 seconds hidden, when the
|
|
15
|
+
network comes back and when the browser restores the page (see
|
|
16
|
+
chats/recovery.js), and reruns a refresh Turbo dropped because it
|
|
17
|
+
arrived mid-visit. It wraps the <turbo-cable-stream-source> that
|
|
18
|
+
`turbo_stream_from` renders: the stream's own confirmations are how it
|
|
19
|
+
sees a reconnect. One per page: don't wrap it in a reconciler of your
|
|
20
|
+
own as well. %>
|
|
17
21
|
|
|
18
22
|
<%# The OTHER inbox-staleness case, distinct from a missed broadcast: a Turbo
|
|
19
23
|
RESTORATION visit (browser back / Hotwire Native stack pop) serves the
|
|
20
24
|
inbox's cached snapshot with no GET. Open a chat from a profile, send a
|
|
21
25
|
message, tap back — the snapshot cached before the chat existed comes back
|
|
22
26
|
and the new conversation is missing until you navigate away and return.
|
|
23
|
-
The
|
|
24
|
-
|
|
27
|
+
The wrapper above can't help (it fires on reconnect and visibility, not
|
|
28
|
+
on a restore visit). `turbo-cache-control: no-cache` makes the inbox
|
|
25
29
|
uncacheable, so a restore visit re-fetches from the network and is always
|
|
26
30
|
fresh — on the web AND in Hotwire Native, whose back-pop issues a Turbo
|
|
27
31
|
`.restore` visit that honors this. Requires the host layout to
|
|
@@ -35,7 +39,8 @@
|
|
|
35
39
|
<%= turbo_stream_from chats_current_messager, :chats_inbox %>
|
|
36
40
|
</div>
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
<%# data-chats-dock-hide: the messaging dock hides itself on this page. %>
|
|
43
|
+
<div class="chats chats-inbox" data-chats-dock-hide>
|
|
39
44
|
<header class="chats-inbox__header">
|
|
40
45
|
<h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
|
|
41
46
|
</header>
|
|
@@ -48,15 +53,24 @@
|
|
|
48
53
|
<% end %>
|
|
49
54
|
|
|
50
55
|
<% if Chats.config.search && !@inbox.filtered? %>
|
|
51
|
-
|
|
56
|
+
<%# The inbox's own path exactly as it was opened (/messages or
|
|
57
|
+
/messages/: the engine answers both), so the URL a search puts in the
|
|
58
|
+
address bar is still this page to Turbo, and the next refresh morphs
|
|
59
|
+
it instead of re-rendering it from scratch. %>
|
|
60
|
+
<%= form_with url: request.original_fullpath.split("?").first,
|
|
52
61
|
method: :get,
|
|
53
62
|
class: "chats-search",
|
|
54
|
-
role
|
|
63
|
+
# `html:`: form_with drops attributes it doesn't know (role among them).
|
|
64
|
+
html: { role: "search" },
|
|
55
65
|
data: {
|
|
56
66
|
controller: "chats--debounced-submit",
|
|
57
67
|
action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
|
|
58
68
|
"chats--debounced-submit-delay-value": 250,
|
|
59
|
-
turbo_frame: "chats_inbox_results"
|
|
69
|
+
turbo_frame: "chats_inbox_results",
|
|
70
|
+
# The query goes in the URL (replaced, not pushed): a page
|
|
71
|
+
# refresh re-requests the URL, so a live update or a catch-up
|
|
72
|
+
# keeps the results and the field as they are.
|
|
73
|
+
turbo_action: "replace"
|
|
60
74
|
} do %>
|
|
61
75
|
<%= search_field_tag :q, params[:q],
|
|
62
76
|
placeholder: t("chats.inbox.search_placeholder"),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
<%# Stale-thread catch-up payload (ConversationsController#refresh):
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
<%# Stale-thread catch-up payload (ConversationsController#refresh): what the
|
|
2
|
+
stream would have delivered while the client wasn't listening. Appends
|
|
3
|
+
are idempotent by dom_id, so a message that raced in through the live
|
|
4
|
+
stream during the fetch can't duplicate. %>
|
|
5
5
|
<%= turbo_stream.append dom_id(@conversation, :messages) do %>
|
|
6
6
|
<%= render partial: "chats/messages/message", collection: @new_messages, as: :message %>
|
|
7
7
|
<% end if @new_messages.any? %>
|
|
@@ -11,3 +11,23 @@
|
|
|
11
11
|
partial: "chats/messages/message",
|
|
12
12
|
locals: { message: message } %>
|
|
13
13
|
<% end %>
|
|
14
|
+
|
|
15
|
+
<%# Who has read what: a read the stream missed would leave "Sent" where it
|
|
16
|
+
should say "Seen". %>
|
|
17
|
+
<%= turbo_stream.replace dom_id(@conversation, :read_state),
|
|
18
|
+
partial: "chats/conversations/read_state",
|
|
19
|
+
locals: { conversation: @conversation } %>
|
|
20
|
+
|
|
21
|
+
<%# `deletion: :hard` only: every message from the client's oldest bubble on
|
|
22
|
+
that still exists (the chats_prune action, registered by the thread
|
|
23
|
+
controller, removes the rest). %>
|
|
24
|
+
<%# Where the client stands now (ConversationsController#caught_up_cursors):
|
|
25
|
+
its cursors move only when an answer like this one arrives. %>
|
|
26
|
+
<%= @caught_up %>
|
|
27
|
+
|
|
28
|
+
<% if @present %>
|
|
29
|
+
<%= turbo_stream_action_tag :chats_prune,
|
|
30
|
+
target: dom_id(@conversation, :history),
|
|
31
|
+
"data-ids": @present[:ids].join(" "),
|
|
32
|
+
"data-until": @present[:until] %>
|
|
33
|
+
<% end %>
|
|
@@ -1,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 %>
|