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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1037 -0
  3. data/README.md +621 -40
  4. data/app/assets/stylesheets/chats.css +906 -2
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +396 -86
  7. data/app/controllers/chats/messages_controller.rb +35 -13
  8. data/app/controllers/chats/reactions_controller.rb +14 -0
  9. data/app/helpers/chats/engine_helper.rb +230 -7
  10. data/app/javascript/chats/composer_controller.js +14 -1
  11. data/app/javascript/chats/dock_controller.js +1120 -0
  12. data/app/javascript/chats/recovery.js +539 -0
  13. data/app/javascript/chats/refresh_inbox_controller.js +76 -0
  14. data/app/javascript/chats/split_controller.js +131 -0
  15. data/app/javascript/chats/thread_controller.js +322 -60
  16. data/app/javascript/chats/unread_badge_controller.js +24 -0
  17. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  18. data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
  19. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  20. data/app/views/chats/conversations/_group.html.erb +61 -0
  21. data/app/views/chats/conversations/_history.html.erb +54 -0
  22. data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
  23. data/app/views/chats/conversations/_thread.html.erb +147 -0
  24. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  25. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  26. data/app/views/chats/conversations/dock.html.erb +40 -0
  27. data/app/views/chats/conversations/index.html.erb +142 -15
  28. data/app/views/chats/conversations/pane.html.erb +14 -0
  29. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  30. data/app/views/chats/conversations/show.html.erb +18 -134
  31. data/app/views/chats/conversations/window.html.erb +26 -0
  32. data/app/views/chats/dock/_badge.html.erb +12 -0
  33. data/app/views/chats/dock/_dock.html.erb +139 -0
  34. data/app/views/chats/dock/_signal.html.erb +26 -0
  35. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  36. data/app/views/chats/dock/_window.html.erb +23 -0
  37. data/app/views/chats/dock/_window_header.html.erb +75 -0
  38. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  39. data/app/views/chats/messages/_action.html.erb +15 -0
  40. data/app/views/chats/messages/_composer.html.erb +84 -56
  41. data/app/views/chats/messages/_message.html.erb +43 -15
  42. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  43. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  44. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  45. data/config/importmap.rb +10 -1
  46. data/config/locales/en.yml +40 -0
  47. data/config/locales/es.yml +40 -0
  48. data/config/routes.rb +8 -0
  49. data/context7.json +4 -0
  50. data/docs/PRD.md +1 -1
  51. data/docs/campfire_review.md +1 -1
  52. data/docs/screenshots/composer-actions.png +0 -0
  53. data/docs/screenshots/dock-bar.png +0 -0
  54. data/docs/screenshots/dock-desktop.png +0 -0
  55. data/docs/screenshots/dock-phone-button.png +0 -0
  56. data/docs/screenshots/dock-phone-panel.png +0 -0
  57. data/docs/screenshots/dock-phone-window.png +0 -0
  58. data/docs/screenshots/inbox.png +0 -0
  59. data/docs/screenshots/split-inbox-empty.png +0 -0
  60. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  61. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  62. data/docs/screenshots/split-inbox.png +0 -0
  63. data/docs/screenshots/system-note-action.png +0 -0
  64. data/docs/screenshots/thread-actions-phone.png +0 -0
  65. data/gemfiles/rails_7.1.gemfile +3 -0
  66. data/gemfiles/rails_7.2.gemfile +3 -0
  67. data/gemfiles/rails_8.1.gemfile +3 -0
  68. data/lib/chats/action.rb +85 -0
  69. data/lib/chats/broadcasts.rb +178 -17
  70. data/lib/chats/configuration.rb +115 -1
  71. data/lib/chats/engine.rb +29 -7
  72. data/lib/chats/errors.rb +16 -0
  73. data/lib/chats/inbox.rb +363 -0
  74. data/lib/chats/inbox_group.rb +75 -0
  75. data/lib/chats/inbox_refresh_job.rb +14 -0
  76. data/lib/chats/macros.rb +33 -1
  77. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  78. data/lib/chats/models/concerns/messager.rb +122 -6
  79. data/lib/chats/models/conversation.rb +153 -19
  80. data/lib/chats/models/message.rb +292 -21
  81. data/lib/chats/models/participant.rb +67 -0
  82. data/lib/chats/models/reaction.rb +18 -0
  83. data/lib/chats/send_rate_limited.rb +30 -0
  84. data/lib/chats/subscribers.rb +156 -0
  85. data/lib/chats/version.rb +1 -1
  86. data/lib/chats.rb +123 -15
  87. data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
  88. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  89. data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
  90. data/lib/generators/chats/templates/initializer.rb +125 -14
  91. data/lib/generators/chats/upgrade_generator.rb +79 -0
  92. data/lib/generators/chats/views_generator.rb +3 -2
  93. 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
- <li class="chats-row <%= "chats-row--unread" if unread_count.positive? %>">
5
- <%= link_to conversation_path(conversation), class: "chats-row__link" do %>
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
- <span class="chats-row__title"><%= conversation.title_for(viewer) %></span>
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 %>