chats 0.1.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 +1037 -0
- data/README.md +621 -40
- data/app/assets/stylesheets/chats.css +906 -2
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +396 -86
- data/app/controllers/chats/messages_controller.rb +35 -13
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +230 -7
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1120 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +76 -0
- data/app/javascript/chats/split_controller.js +131 -0
- data/app/javascript/chats/thread_controller.js +322 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +61 -0
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
- data/app/views/chats/conversations/_thread.html.erb +147 -0
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +142 -15
- data/app/views/chats/conversations/pane.html.erb +14 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +18 -134
- 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 +139 -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/_action.html.erb +15 -0
- data/app/views/chats/messages/_composer.html.erb +84 -56
- data/app/views/chats/messages/_message.html.erb +43 -15
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +10 -1
- data/config/locales/en.yml +40 -0
- data/config/locales/es.yml +40 -0
- data/config/routes.rb +8 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/docs/screenshots/composer-actions.png +0 -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/docs/screenshots/inbox.png +0 -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/docs/screenshots/system-note-action.png +0 -0
- data/docs/screenshots/thread-actions-phone.png +0 -0
- data/gemfiles/rails_7.1.gemfile +3 -0
- data/gemfiles/rails_7.2.gemfile +3 -0
- data/gemfiles/rails_8.1.gemfile +3 -0
- data/lib/chats/action.rb +85 -0
- data/lib/chats/broadcasts.rb +178 -17
- data/lib/chats/configuration.rb +115 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +363 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/macros.rb +33 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +122 -6
- data/lib/chats/models/conversation.rb +153 -19
- data/lib/chats/models/message.rb +292 -21
- data/lib/chats/models/participant.rb +67 -0
- data/lib/chats/models/reaction.rb +18 -0
- data/lib/chats/send_rate_limited.rb +30 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +123 -15
- data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
- data/lib/generators/chats/templates/initializer.rb +125 -14
- data/lib/generators/chats/upgrade_generator.rb +79 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +51 -2
|
@@ -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 %>
|
|
@@ -1,13 +1,46 @@
|
|
|
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).
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
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 %>
|
|
6
28
|
<%= chats_conversation_avatar(conversation, viewer) %>
|
|
7
29
|
|
|
8
30
|
<span class="chats-row__body">
|
|
9
31
|
<span class="chats-row__top">
|
|
10
|
-
|
|
32
|
+
<%# The badge sits OUTSIDE the ellipsizing title so a long name
|
|
33
|
+
truncates without ever eating the mark. Nil for an ordinary
|
|
34
|
+
counterpart and for groups, so the row is unchanged. %>
|
|
35
|
+
<span class="chats-row__name">
|
|
36
|
+
<span class="chats-row__title"><%= conversation.title_for(viewer) %></span>
|
|
37
|
+
<%= chats_verified_badge(conversation.counterpart_for(viewer)) %>
|
|
38
|
+
<% if chats_muted?(conversation, viewer) %>
|
|
39
|
+
<svg class="chats-row__muted" role="img" aria-label="<%= t("chats.dock.muted") %>" viewBox="0 0 24 24"
|
|
40
|
+
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
41
|
+
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>
|
|
42
|
+
<% end %>
|
|
43
|
+
</span>
|
|
11
44
|
<time class="chats-row__time" datetime="<%= conversation.last_message_at&.iso8601 %>">
|
|
12
45
|
<%= chats_timestamp(conversation.last_message_at) %>
|
|
13
46
|
</time>
|
|
@@ -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 %>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<%# One STACKED inbox row: every direct conversation the viewer has with a
|
|
2
|
+
`acts_as_messager inbox: :grouped` counterpart (a support desk, a
|
|
3
|
+
storefront), folded into a single line — their avatar and name, the
|
|
4
|
+
freshest message across the stack, and the aggregate unread badge.
|
|
5
|
+
|
|
6
|
+
A stack of one links straight to that thread (and the thread offers a
|
|
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.
|
|
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. 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) %>"
|
|
19
|
+
id="<%= group.dom_id %>">
|
|
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 %>
|
|
32
|
+
<%= chats_messager_avatar(group.messager) %>
|
|
33
|
+
|
|
34
|
+
<span class="chats-row__body">
|
|
35
|
+
<span class="chats-row__top">
|
|
36
|
+
<span class="chats-row__name">
|
|
37
|
+
<span class="chats-row__title"><%= group.title_for(viewer) %></span>
|
|
38
|
+
<%= chats_verified_badge(group.messager) %>
|
|
39
|
+
</span>
|
|
40
|
+
<time class="chats-row__time" datetime="<%= group.last_message_at&.iso8601 %>">
|
|
41
|
+
<%= chats_timestamp(group.last_message_at) %>
|
|
42
|
+
</time>
|
|
43
|
+
</span>
|
|
44
|
+
|
|
45
|
+
<% if group.open_count > 1 %>
|
|
46
|
+
<span class="chats-row__subject"><%= t("chats.inbox.group_count", count: group.open_count) %></span>
|
|
47
|
+
<% elsif group.conversation&.subject_label %>
|
|
48
|
+
<span class="chats-row__subject"><%= group.conversation.subject_label %></span>
|
|
49
|
+
<% end %>
|
|
50
|
+
|
|
51
|
+
<span class="chats-row__bottom">
|
|
52
|
+
<span class="chats-row__preview">
|
|
53
|
+
<%= group.conversation ? chats_preview_for(group.conversation, viewer) : "" %>
|
|
54
|
+
</span>
|
|
55
|
+
<% if group.unread? %>
|
|
56
|
+
<span class="chats-badge"><%= group.unread_count > 99 ? "99+" : group.unread_count %></span>
|
|
57
|
+
<% end %>
|
|
58
|
+
</span>
|
|
59
|
+
</span>
|
|
60
|
+
<% end %>
|
|
61
|
+
</li>
|
|
@@ -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>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%# What sits where the composer would be when the conversation's SUBJECT
|
|
2
|
+
has locked it (Chats::ChatSubject#chat_locked?). It carries the same DOM
|
|
3
|
+
id as the composer's dock, so MessagesController#create can swap one for
|
|
4
|
+
the other with a single Turbo replace when a thread closes under an open
|
|
5
|
+
composer — the host's composer_actions leave with it.
|
|
6
|
+
|
|
7
|
+
The `locked_composer` slot replaces the BODY (a reopen button, a link to
|
|
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 %>
|
|
14
|
+
<div id="<%= dom_id(conversation, :composer) %>" class="chats-composer chats-composer--locked">
|
|
15
|
+
<% if chats_slot?(:locked_composer) %>
|
|
16
|
+
<%= chats_slot :locked_composer, conversation: conversation, notice: notice %>
|
|
17
|
+
<% else %>
|
|
18
|
+
<p class="chats-composer__locked-notice"><%= notice %></p>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<%# The thread body: everything live about one open conversation, shared by
|
|
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.
|
|
8
|
+
|
|
9
|
+
One conversation stream powers everything here: message
|
|
10
|
+
append/replace/remove, the read-state payload, and typing pings. Bubbles
|
|
11
|
+
arrive VIEWER-AGNOSTIC from shared broadcasts; the chats--thread Stimulus
|
|
12
|
+
controller aligns own-vs-other by comparing each bubble's sender key
|
|
13
|
+
against its `me` value (that's what makes one broadcast render work for
|
|
14
|
+
every subscriber).
|
|
15
|
+
|
|
16
|
+
The DOM ids (messages, composer, composer_errors, read_state, typing) are
|
|
17
|
+
the targets the streams address, so a conversation is rendered at most
|
|
18
|
+
ONCE per page: the dock never opens a window for a conversation the page
|
|
19
|
+
already shows.
|
|
20
|
+
|
|
21
|
+
Locals:
|
|
22
|
+
conversation the Chats::Conversation
|
|
23
|
+
messages the latest page, oldest first
|
|
24
|
+
more whether older pages exist (the lazy frame chain)
|
|
25
|
+
first_unread_id where the «new messages» line goes, or nil
|
|
26
|
+
counterpart the other messager of a direct thread, or nil
|
|
27
|
+
window true in a dock window: rooted at .chats-thread--window
|
|
28
|
+
(never .chats, which is the full-page shell), and the
|
|
29
|
+
server did NOT read this render — the controller
|
|
30
|
+
posts #read once the window is on screen and the
|
|
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).
|
|
39
|
+
|
|
40
|
+
The page's root carries data-chats-dock-hide: the messaging dock hides
|
|
41
|
+
itself on any page that has it. %>
|
|
42
|
+
<% window = local_assigns.fetch(:window, false) %>
|
|
43
|
+
<% pane = local_assigns.fetch(:pane, false) %>
|
|
44
|
+
<% read_pending = window || local_assigns.fetch(:read_pending, false) %>
|
|
45
|
+
<% viewer = chats_current_messager %>
|
|
46
|
+
<%# A direct thread nobody may write in any more says so up front, in the
|
|
47
|
+
composer's place — the host's policy turned false, or the other account
|
|
48
|
+
is gone — instead of letting a send fail. Same id as the composer, so
|
|
49
|
+
the live lock swap still finds it. %>
|
|
50
|
+
<% refused = !conversation.locked? && conversation.direct? &&
|
|
51
|
+
(counterpart.nil? || !Chats.can_message?(viewer, counterpart)) %>
|
|
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: {
|
|
57
|
+
controller: "chats--thread",
|
|
58
|
+
"chats-dock-hide": (true unless window || pane), # the pane's page carries it
|
|
59
|
+
"chats--thread-me-value": Chats.messager_key(viewer),
|
|
60
|
+
"chats--thread-read-url-value": read_conversation_path(conversation),
|
|
61
|
+
"chats--thread-read-pending-value": read_pending,
|
|
62
|
+
"chats--thread-sent-label-value": t("chats.thread.sent"),
|
|
63
|
+
"chats--thread-seen-label-value": Chats.config.read_receipts ? t("chats.thread.seen") : "",
|
|
64
|
+
"chats--thread-today-label-value": t("chats.thread.today"),
|
|
65
|
+
"chats--thread-yesterday-label-value": t("chats.thread.yesterday"),
|
|
66
|
+
"chats--thread-typing-suffix-value": t("chats.thread.typing_suffix"),
|
|
67
|
+
"chats--thread-group-value": conversation.group?,
|
|
68
|
+
"chats--thread-refresh-url-value": refresh_conversation_path(conversation),
|
|
69
|
+
"chats--thread-thread-url-value": conversation_path(conversation),
|
|
70
|
+
"chats--thread-copied-label-value": t("chats.message.copied"),
|
|
71
|
+
# A plain data attribute (not a Stimulus value): host CSS/JS keys its
|
|
72
|
+
# own block/report affordances off it. False for a messager declared
|
|
73
|
+
# `acts_as_messager blockable: false`.
|
|
74
|
+
"chats-blockable": chats_blockable?(counterpart),
|
|
75
|
+
action: "pointerdown->chats--thread#pressStart pointermove->chats--thread#pressMove " \
|
|
76
|
+
"pointerup->chats--thread#pressEnd pointercancel->chats--thread#pressCancel " \
|
|
77
|
+
"contextmenu->chats--thread#contextMenu"
|
|
78
|
+
} do %>
|
|
79
|
+
|
|
80
|
+
<%= turbo_stream_from conversation %>
|
|
81
|
+
|
|
82
|
+
<%= yield %>
|
|
83
|
+
|
|
84
|
+
<%# tabindex: the arrow keys scroll the history (WCAG 2.1.1). The id is
|
|
85
|
+
what the stale-thread catch-up re-renders when the thread fell more
|
|
86
|
+
than a page behind (ConversationsController#refresh). %>
|
|
87
|
+
<div id="<%= dom_id(conversation, :history) %>" class="chats-thread__scroll" data-chats--thread-target="scroller" tabindex="0">
|
|
88
|
+
<%= render "chats/conversations/history", conversation: conversation, messages: messages, more: more,
|
|
89
|
+
first_unread_id: first_unread_id, counterpart: counterpart,
|
|
90
|
+
window: window %>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
|
|
94
|
+
pressed bubble's clone morphs to the center, the reactions pill sits
|
|
95
|
+
above it and the contextual menu below. Slots are filled by cloning
|
|
96
|
+
the bubble's <template data-chats-message-menu>. %>
|
|
97
|
+
<div class="chats-popup"
|
|
98
|
+
role="dialog"
|
|
99
|
+
aria-modal="true"
|
|
100
|
+
data-chats--thread-target="popup"
|
|
101
|
+
hidden>
|
|
102
|
+
<div class="chats-popup__backdrop" data-action="click->chats--thread#closePopup"></div>
|
|
103
|
+
<div class="chats-popup__stack">
|
|
104
|
+
<div class="chats-popup__slot chats-popup__slot--reactions"
|
|
105
|
+
data-chats--thread-target="popupReactions"
|
|
106
|
+
data-action="click->chats--thread#popupMenuClicked"></div>
|
|
107
|
+
<div class="chats-popup__slot chats-popup__slot--bubble" data-chats--thread-target="popupBubble"></div>
|
|
108
|
+
<div class="chats-popup__slot chats-popup__slot--menu"
|
|
109
|
+
data-chats--thread-target="popupMenu"
|
|
110
|
+
data-action="click->chats--thread#popupMenuClicked"></div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div class="chats-attachment-preview"
|
|
115
|
+
role="dialog"
|
|
116
|
+
aria-modal="true"
|
|
117
|
+
tabindex="-1"
|
|
118
|
+
data-chats--thread-target="attachmentDialog"
|
|
119
|
+
data-action="keydown.esc->chats--thread#closeAttachment"
|
|
120
|
+
hidden>
|
|
121
|
+
<div class="chats-attachment-preview__surface"
|
|
122
|
+
data-action="click->chats--thread#closeAttachmentFromBackdrop">
|
|
123
|
+
<button type="button"
|
|
124
|
+
class="chats-attachment-preview__close"
|
|
125
|
+
aria-label="<%= t("chats.message.close_attachment") %>"
|
|
126
|
+
data-action="chats--thread#closeAttachment">×</button>
|
|
127
|
+
<figure class="chats-attachment-preview__figure">
|
|
128
|
+
<img class="chats-attachment-preview__image"
|
|
129
|
+
alt=""
|
|
130
|
+
data-chats--thread-target="attachmentImage">
|
|
131
|
+
</figure>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<%# A locked conversation (its SUBJECT says so — Chats::ChatSubject#
|
|
136
|
+
chat_locked?) keeps its whole history readable and swaps the composer
|
|
137
|
+
for the reason it's closed. Never a hidden screen: gate the action,
|
|
138
|
+
explain it in place. %>
|
|
139
|
+
<% if conversation.locked? %>
|
|
140
|
+
<%= render "chats/conversations/locked_composer", conversation: conversation %>
|
|
141
|
+
<% elsif refused %>
|
|
142
|
+
<%= render "chats/conversations/locked_composer", conversation: conversation,
|
|
143
|
+
notice: t("chats.flashes.blocked") %>
|
|
144
|
+
<% else %>
|
|
145
|
+
<%= render "chats/messages/composer", conversation: conversation %>
|
|
146
|
+
<% end %>
|
|
147
|
+
<% end %>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<%# The header over a conversation: the thread page's, and the conversation
|
|
2
|
+
pane's on the two-pane messaging page (chats/conversations/
|
|
3
|
+
_conversation_pane). Back to the list, who it is with (avatar, name,
|
|
4
|
+
the verified badge, what it is about, the way back to their stack) and
|
|
5
|
+
the ⋯ menu (mute, leave, the host's conversation_header_actions).
|
|
6
|
+
|
|
7
|
+
The back link always renders: on the two-pane page chats.css hides it
|
|
8
|
+
while the list is on screen beside the conversation.
|
|
9
|
+
|
|
10
|
+
Locals:
|
|
11
|
+
conversation the Chats::Conversation
|
|
12
|
+
participant the viewer's seat in it (mute state), or nil
|
|
13
|
+
counterpart the other messager of a direct thread, or nil
|
|
14
|
+
heading the title's element: :h1 on the thread page, :h2 in the
|
|
15
|
+
pane, under the page's own "Messages" heading
|
|
16
|
+
title_id the title's id, which a pane is labelled by (optional);
|
|
17
|
+
the pane's title also takes the focus when a row opens
|
|
18
|
+
it on a touch screen (tabindex="-1", see chats--split)
|
|
19
|
+
back_path where the back link goes (default: the inbox; the pane
|
|
20
|
+
keeps the list's search or stack) %>
|
|
21
|
+
<% heading = local_assigns.fetch(:heading, :h1) %>
|
|
22
|
+
<% title_id = local_assigns[:title_id] %>
|
|
23
|
+
<header class="chats-thread__header">
|
|
24
|
+
<%= link_to local_assigns[:back_path] || root_path, class: "chats-thread__back", "aria-label": t("chats.thread.back") do %>
|
|
25
|
+
<span aria-hidden="true">‹</span>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<%# The counterpart is already resolved for the title, the profile link
|
|
29
|
+
and the stack link — reuse it here instead of asking again. %>
|
|
30
|
+
<% if counterpart %>
|
|
31
|
+
<%= chats_messager_avatar(counterpart) %>
|
|
32
|
+
<% else %>
|
|
33
|
+
<%= chats_conversation_avatar(conversation, chats_current_messager) %>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<div class="chats-thread__identity">
|
|
37
|
+
<%# Both arms wrap the name in .chats-thread__name: the heading is a flex
|
|
38
|
+
row (the badge is its second child), and only a real element can
|
|
39
|
+
carry the ellipsis. %>
|
|
40
|
+
<%= content_tag heading, class: "chats-thread__title", id: title_id, tabindex: (-1 if title_id) do %>
|
|
41
|
+
<% if counterpart %>
|
|
42
|
+
<%= chats_messager_name(counterpart, css_class: "chats-thread__name") %>
|
|
43
|
+
<%= chats_verified_badge(counterpart) %>
|
|
44
|
+
<% else %>
|
|
45
|
+
<span class="chats-thread__name"><%= conversation.title_for(chats_current_messager) %></span>
|
|
46
|
+
<% end %>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% if conversation.subject_label %>
|
|
49
|
+
<p class="chats-thread__subject"><%= conversation.subject_label %></p>
|
|
50
|
+
<% end %>
|
|
51
|
+
<%# A stacked counterpart's thread is one of many: offer the way back
|
|
52
|
+
to the whole stack (chats' filtered inbox, or the host's own
|
|
53
|
+
screen via `group_path:`). %>
|
|
54
|
+
<% if Chats.grouped_inbox?(counterpart) %>
|
|
55
|
+
<p class="chats-thread__see-all">
|
|
56
|
+
<%= link_to t("chats.thread.see_all"), chats_group_path_for(counterpart), class: "chats-thread__see-all-link" %>
|
|
57
|
+
</p>
|
|
58
|
+
<% end %>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<details class="chats-menu">
|
|
62
|
+
<summary class="chats-menu__trigger" aria-label="<%= t("chats.thread.options") %>">⋯</summary>
|
|
63
|
+
<div class="chats-menu__panel">
|
|
64
|
+
<% if participant&.muted? %>
|
|
65
|
+
<%= button_to t("chats.thread.unmute"), unmute_conversation_path(conversation), method: :post, class: "chats-menu__item" %>
|
|
66
|
+
<% else %>
|
|
67
|
+
<%= button_to t("chats.thread.mute"), mute_conversation_path(conversation), method: :post, class: "chats-menu__item" %>
|
|
68
|
+
<% end %>
|
|
69
|
+
<% if conversation.group? %>
|
|
70
|
+
<%= button_to t("chats.thread.leave"), leave_conversation_path(conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
|
|
71
|
+
<% end %>
|
|
72
|
+
<%# Slot: the host's own thread actions (block, report, archive…).
|
|
73
|
+
`blockable` is false for messagers declared
|
|
74
|
+
`acts_as_messager blockable: false`, so safety affordances
|
|
75
|
+
disappear against a support desk without any class check. %>
|
|
76
|
+
<%= chats_slot :conversation_header_actions,
|
|
77
|
+
conversation: conversation,
|
|
78
|
+
viewer: chats_current_messager,
|
|
79
|
+
counterpart: counterpart,
|
|
80
|
+
blockable: chats_blockable?(counterpart) %>
|
|
81
|
+
</div>
|
|
82
|
+
</details>
|
|
83
|
+
</header>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# Every unread badge on the page (ConversationsController#badge), as the
|
|
2
|
+
badge streams would have sent them: the nav badge
|
|
3
|
+
(Chats::Broadcasts.update_badge_of) and the dock's (chats/dock/_update).
|
|
4
|
+
A page without one of them simply has nothing to replace. %>
|
|
5
|
+
<%= turbo_stream.replace "chats_unread_badge", partial: "chats/shared/unread_badge", locals: { count: count } %>
|
|
6
|
+
<%= turbo_stream.replace_all ".chats-dock [data-chats-unread-badge]", partial: "chats/dock/badge", locals: { count: count } %>
|
|
@@ -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 %>
|