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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +478 -0
  3. data/README.md +150 -10
  4. data/app/assets/stylesheets/chats.css +599 -0
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +273 -55
  7. data/app/helpers/chats/engine_helper.rb +102 -6
  8. data/app/javascript/chats/composer_controller.js +14 -1
  9. data/app/javascript/chats/dock_controller.js +1106 -0
  10. data/app/javascript/chats/recovery.js +539 -0
  11. data/app/javascript/chats/refresh_inbox_controller.js +55 -65
  12. data/app/javascript/chats/thread_controller.js +317 -60
  13. data/app/javascript/chats/unread_badge_controller.js +24 -0
  14. data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
  15. data/app/views/chats/conversations/_group.html.erb +6 -2
  16. data/app/views/chats/conversations/_history.html.erb +54 -0
  17. data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
  18. data/app/views/chats/conversations/_thread.html.erb +133 -0
  19. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  20. data/app/views/chats/conversations/dock.html.erb +40 -0
  21. data/app/views/chats/conversations/index.html.erb +24 -10
  22. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  23. data/app/views/chats/conversations/show.html.erb +11 -118
  24. data/app/views/chats/conversations/window.html.erb +26 -0
  25. data/app/views/chats/dock/_badge.html.erb +12 -0
  26. data/app/views/chats/dock/_dock.html.erb +138 -0
  27. data/app/views/chats/dock/_signal.html.erb +26 -0
  28. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  29. data/app/views/chats/dock/_window.html.erb +23 -0
  30. data/app/views/chats/dock/_window_header.html.erb +75 -0
  31. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  32. data/app/views/chats/messages/_composer.html.erb +11 -1
  33. data/app/views/chats/messages/_message.html.erb +8 -6
  34. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  35. data/config/importmap.rb +7 -0
  36. data/config/locales/en.yml +17 -0
  37. data/config/locales/es.yml +17 -0
  38. data/config/routes.rb +8 -0
  39. data/docs/screenshots/dock-bar.png +0 -0
  40. data/docs/screenshots/dock-desktop.png +0 -0
  41. data/docs/screenshots/dock-phone-button.png +0 -0
  42. data/docs/screenshots/dock-phone-panel.png +0 -0
  43. data/docs/screenshots/dock-phone-window.png +0 -0
  44. data/gemfiles/rails_7.1.gemfile +2 -0
  45. data/gemfiles/rails_7.2.gemfile +2 -0
  46. data/gemfiles/rails_8.1.gemfile +2 -0
  47. data/lib/chats/broadcasts.rb +178 -17
  48. data/lib/chats/inbox.rb +68 -30
  49. data/lib/chats/inbox_refresh_job.rb +14 -0
  50. data/lib/chats/models/concerns/messager.rb +10 -2
  51. data/lib/chats/models/conversation.rb +57 -0
  52. data/lib/chats/models/message.rb +98 -9
  53. data/lib/chats/models/reaction.rb +13 -0
  54. data/lib/chats/version.rb +1 -1
  55. data/lib/generators/chats/templates/initializer.rb +1 -0
  56. data/lib/generators/chats/views_generator.rb +3 -2
  57. 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" do %>
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" do %>
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: conversation.locked_notice %>
16
+ <%= chats_slot :locked_composer, conversation: conversation, notice: notice %>
12
17
  <% else %>
13
- <p class="chats-composer__locked-notice"><%= conversation.locked_notice %></p>
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 on cable reconnect
14
- and on return-to-visible. It needs to wrap the <turbo-cable-stream-source>
15
- that `turbo_stream_from` renders so it can observe its `connected`
16
- attribute as the heartbeat. %>
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 reconciler above can't help (it only fires on reconnect / visibility,
24
- not on a restore visit). `turbo-cache-control: no-cache` makes the inbox
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
- <div class="chats chats-inbox">
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
- <%= form_with url: conversations_path,
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: "search",
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
- append what arrived while the client slept, replace what changed.
3
- Appends are idempotent by dom_id, so a message that raced in through
4
- the live stream during the fetch can't duplicate. %>
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. One conversation stream powers everything live here:
2
- message append/replace/remove, the read-state payload, and typing pings.
3
- Bubbles arrive VIEWER-AGNOSTIC from shared broadcasts; the chats--thread
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
- <%= tag.div class: "chats chats-thread", data: {
10
- controller: "chats--thread",
11
- "chats--thread-me-value": Chats.messager_key(chats_current_messager),
12
- "chats--thread-read-url-value": read_conversation_path(@conversation),
13
- "chats--thread-sent-label-value": t("chats.thread.sent"),
14
- "chats--thread-seen-label-value": Chats.config.read_receipts ? t("chats.thread.seen") : "",
15
- "chats--thread-today-label-value": t("chats.thread.today"),
16
- "chats--thread-yesterday-label-value": t("chats.thread.yesterday"),
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 %>