chats 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +43 -0
  3. data/.simplecov +52 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +17 -0
  6. data/CHANGELOG.md +74 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +384 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/chats.css +818 -0
  12. data/app/controllers/chats/application_controller.rb +65 -0
  13. data/app/controllers/chats/conversations_controller.rb +198 -0
  14. data/app/controllers/chats/messages_controller.rb +118 -0
  15. data/app/controllers/chats/reactions_controller.rb +33 -0
  16. data/app/helpers/chats/engine_helper.rb +212 -0
  17. data/app/javascript/chats/composer_controller.js +258 -0
  18. data/app/javascript/chats/debounced_submit_controller.js +40 -0
  19. data/app/javascript/chats/thread_controller.js +855 -0
  20. data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
  21. data/app/views/chats/conversations/_messages_page.html.erb +16 -0
  22. data/app/views/chats/conversations/_read_state.html.erb +11 -0
  23. data/app/views/chats/conversations/index.html.erb +54 -0
  24. data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
  25. data/app/views/chats/conversations/show.html.erb +137 -0
  26. data/app/views/chats/messages/_composer.html.erb +67 -0
  27. data/app/views/chats/messages/_message.html.erb +158 -0
  28. data/app/views/chats/messages/create.turbo_stream.erb +6 -0
  29. data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
  30. data/app/views/chats/shared/_unread_badge.html.erb +6 -0
  31. data/config/importmap.rb +16 -0
  32. data/config/locales/en.yml +87 -0
  33. data/config/locales/es.yml +87 -0
  34. data/config/routes.rb +24 -0
  35. data/docs/PRD.md +254 -0
  36. data/docs/campfire_review.md +46 -0
  37. data/gemfiles/rails_7.1.gemfile +36 -0
  38. data/gemfiles/rails_7.2.gemfile +36 -0
  39. data/gemfiles/rails_8.1.gemfile +36 -0
  40. data/lib/chats/broadcasts.rb +147 -0
  41. data/lib/chats/configuration.rb +286 -0
  42. data/lib/chats/engine.rb +146 -0
  43. data/lib/chats/errors.rb +20 -0
  44. data/lib/chats/macros.rb +28 -0
  45. data/lib/chats/models/application_record.rb +11 -0
  46. data/lib/chats/models/concerns/chat_subject.rb +35 -0
  47. data/lib/chats/models/concerns/messager.rb +102 -0
  48. data/lib/chats/models/conversation.rb +347 -0
  49. data/lib/chats/models/message.rb +323 -0
  50. data/lib/chats/models/participant.rb +151 -0
  51. data/lib/chats/models/reaction.rb +70 -0
  52. data/lib/chats/version.rb +5 -0
  53. data/lib/chats.rb +188 -0
  54. data/lib/generators/chats/install_generator.rb +62 -0
  55. data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
  56. data/lib/generators/chats/templates/initializer.rb +138 -0
  57. data/lib/generators/chats/views_generator.rb +49 -0
  58. metadata +204 -0
@@ -0,0 +1,28 @@
1
+ <%# One inbox row. Rendered per-viewer (this page only updates via full
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 %>
6
+ <%= chats_conversation_avatar(conversation, viewer) %>
7
+
8
+ <span class="chats-row__body">
9
+ <span class="chats-row__top">
10
+ <span class="chats-row__title"><%= conversation.title_for(viewer) %></span>
11
+ <time class="chats-row__time" datetime="<%= conversation.last_message_at&.iso8601 %>">
12
+ <%= chats_timestamp(conversation.last_message_at) %>
13
+ </time>
14
+ </span>
15
+
16
+ <% if conversation.subject_label %>
17
+ <span class="chats-row__subject"><%= conversation.subject_label %></span>
18
+ <% end %>
19
+
20
+ <span class="chats-row__bottom">
21
+ <span class="chats-row__preview"><%= chats_preview_for(conversation, viewer) %></span>
22
+ <% if unread_count.positive? %>
23
+ <span class="chats-badge"><%= unread_count > 99 ? "99+" : unread_count %></span>
24
+ <% end %>
25
+ </span>
26
+ </span>
27
+ <% end %>
28
+ </li>
@@ -0,0 +1,16 @@
1
+ <%# An OLDER page of messages, served into the pagination frame chain.
2
+ The wrapping frame id must equal the requesting frame's id (Turbo matches
3
+ by id), which both sides derive from the same anchor message — the
4
+ `before` param. Inside: the next lazy frame (when more pages exist),
5
+ then this page's messages, which Turbo splices in place of the loader. %>
6
+ <%= turbo_frame_tag "chats_page_#{params[:before]}" do %>
7
+ <% if more && messages.any? %>
8
+ <%= turbo_frame_tag "chats_page_#{messages.first.id}",
9
+ src: conversation_path(conversation, before: messages.first.id),
10
+ loading: :lazy do %>
11
+ <div class="chats-loader"><%= t("chats.thread.loading") %></div>
12
+ <% end %>
13
+ <% end %>
14
+
15
+ <%= render partial: "chats/messages/message", collection: messages, as: :message %>
16
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <%# Viewer-agnostic read-state payload: every active participant's read
2
+ horizon, keyed by messager key. Broadcast-replaced whenever anyone reads
3
+ (Chats::Broadcasts.read_state); the chats--thread controller derives the
4
+ per-viewer "Seen" indicator from it client-side. %>
5
+ <div id="<%= dom_id(conversation, :read_state) %>"
6
+ data-chats--thread-target="readState"
7
+ data-horizons="<%= conversation.participants.active.includes(:messager).each_with_object({}) { |participant, horizons|
8
+ next if participant.messager.nil?
9
+ horizons[Chats.messager_key(participant.messager)] = participant.last_read_at&.iso8601
10
+ }.to_json %>"
11
+ hidden></div>
@@ -0,0 +1,54 @@
1
+ <%# The inbox. Subscribed to the viewer's inbox stream: new activity anywhere
2
+ triggers a Turbo 8 page refresh (morphing, scroll-preserving), which
3
+ re-renders this page per-viewer — see Chats::Broadcasts for why refreshes
4
+ beat surgical row patches here. %>
5
+ <%= chats_styles %>
6
+ <%= turbo_stream_from chats_current_messager, :chats_inbox %>
7
+
8
+ <div class="chats chats-inbox">
9
+ <header class="chats-inbox__header">
10
+ <h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
11
+ </header>
12
+
13
+ <% if Chats.config.search %>
14
+ <%= form_with url: conversations_path,
15
+ method: :get,
16
+ class: "chats-search",
17
+ role: "search",
18
+ data: {
19
+ controller: "chats--debounced-submit",
20
+ action: "input->chats--debounced-submit#queue search->chats--debounced-submit#queue",
21
+ "chats--debounced-submit-delay-value": 250,
22
+ turbo_frame: "chats_inbox_results"
23
+ } do %>
24
+ <%= search_field_tag :q, params[:q],
25
+ placeholder: t("chats.inbox.search_placeholder"),
26
+ class: "chats-search__input",
27
+ autocomplete: "off" %>
28
+ <% end %>
29
+ <% end %>
30
+
31
+ <%= turbo_frame_tag "chats_inbox_results", target: "_top" do %>
32
+ <% if @conversations.any? %>
33
+ <ul class="chats-inbox__list">
34
+ <% @conversations.each do |conversation| %>
35
+ <%= render "chats/conversations/conversation_row",
36
+ conversation: conversation,
37
+ viewer: chats_current_messager,
38
+ unread_count: @unread_counts.fetch(conversation.id, 0) %>
39
+ <% end %>
40
+ </ul>
41
+ <% elsif params[:q].present? %>
42
+ <div class="chats-empty">
43
+ <p class="chats-empty__title"><%= t("chats.inbox.no_results_title") %></p>
44
+ <p class="chats-empty__hint"><%= t("chats.inbox.no_results_hint", query: params[:q]) %></p>
45
+ </div>
46
+ <% else %>
47
+ <div class="chats-empty">
48
+ <span class="chats-empty__icon" aria-hidden="true">💬</span>
49
+ <p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
50
+ <p class="chats-empty__hint"><%= t("chats.inbox.empty_hint") %></p>
51
+ </div>
52
+ <% end %>
53
+ <% end %>
54
+ </div>
@@ -0,0 +1,13 @@
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. %>
5
+ <%= turbo_stream.append dom_id(@conversation, :messages) do %>
6
+ <%= render partial: "chats/messages/message", collection: @new_messages, as: :message %>
7
+ <% end if @new_messages.any? %>
8
+
9
+ <% @updated_messages.each do |message| %>
10
+ <%= turbo_stream.replace dom_id(message),
11
+ partial: "chats/messages/message",
12
+ locals: { message: message } %>
13
+ <% end %>
@@ -0,0 +1,137 @@
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). %>
7
+ <%= chats_styles %>
8
+
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
+ action: "pointerdown->chats--thread#pressStart pointermove->chats--thread#pressMove " \
23
+ "pointerup->chats--thread#pressEnd pointercancel->chats--thread#pressCancel " \
24
+ "contextmenu->chats--thread#contextMenu"
25
+ } do %>
26
+
27
+ <%= turbo_stream_from @conversation %>
28
+
29
+ <header class="chats-thread__header">
30
+ <%= link_to root_path, class: "chats-thread__back", "aria-label": t("chats.thread.back") do %>
31
+ <span aria-hidden="true">‹</span>
32
+ <% end %>
33
+
34
+ <%= chats_conversation_avatar(@conversation, chats_current_messager) %>
35
+
36
+ <div class="chats-thread__identity">
37
+ <h1 class="chats-thread__title"><%= @conversation.title_for(chats_current_messager) %></h1>
38
+ <% if @conversation.subject_label %>
39
+ <p class="chats-thread__subject"><%= @conversation.subject_label %></p>
40
+ <% end %>
41
+ </div>
42
+
43
+ <details class="chats-menu">
44
+ <summary class="chats-menu__trigger" aria-label="<%= t("chats.thread.options") %>">⋯</summary>
45
+ <div class="chats-menu__panel">
46
+ <% if @participant&.muted? %>
47
+ <%= button_to t("chats.thread.unmute"), unmute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
48
+ <% else %>
49
+ <%= button_to t("chats.thread.mute"), mute_conversation_path(@conversation), method: :post, class: "chats-menu__item" %>
50
+ <% end %>
51
+ <% if @conversation.group? %>
52
+ <%= button_to t("chats.thread.leave"), leave_conversation_path(@conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
53
+ <% end %>
54
+ </div>
55
+ </details>
56
+ </header>
57
+
58
+ <div class="chats-thread__scroll" data-chats--thread-target="scroller">
59
+ <% if @more_messages && @messages.any? %>
60
+ <%# Lazy frame chain for infinite scroll-up: when this frame becomes
61
+ visible, Turbo fetches the previous keyset page (?before=oldest id),
62
+ whose response contains a frame with this SAME id — older messages
63
+ plus, recursively, the next frame. See ConversationsController#show. %>
64
+ <%= turbo_frame_tag "chats_page_#{@messages.first.id}",
65
+ src: conversation_path(@conversation, before: @messages.first.id),
66
+ loading: :lazy do %>
67
+ <div class="chats-loader"><%= t("chats.thread.loading") %></div>
68
+ <% end %>
69
+ <% end %>
70
+
71
+ <div id="<%= dom_id(@conversation, :messages) %>" class="chats-thread__messages">
72
+ <%# Explicit loop (not a collection render) so the «new messages»
73
+ divider can sit BEFORE the first unread bubble (WhatsApp's "the
74
+ catch-up starts here" line). Server-rendered on the initial page
75
+ only; it scrolls away naturally as the thread lives on. %>
76
+ <% @messages.each do |message| %>
77
+ <% if message.id == @first_unread_id %>
78
+ <div class="chats-thread__unread-line" role="separator">
79
+ <span><%= t("chats.thread.new_messages") %></span>
80
+ </div>
81
+ <% end %>
82
+ <%= render "chats/messages/message", message: message %>
83
+ <% end %>
84
+ </div>
85
+
86
+ <%= render "chats/conversations/read_state", conversation: @conversation %>
87
+
88
+ <div id="<%= dom_id(@conversation, :typing) %>"
89
+ class="chats-typing"
90
+ data-chats--thread-target="typing"
91
+ hidden></div>
92
+ </div>
93
+
94
+ <%# Long-press popup (Telegram-style): the backdrop blurs the thread, the
95
+ pressed bubble's clone morphs to the center, the reactions pill sits
96
+ above it and the contextual menu below. Slots are filled by cloning
97
+ the bubble's <template data-chats-message-menu>. %>
98
+ <div class="chats-popup"
99
+ role="dialog"
100
+ aria-modal="true"
101
+ data-chats--thread-target="popup"
102
+ hidden>
103
+ <div class="chats-popup__backdrop" data-action="click->chats--thread#closePopup"></div>
104
+ <div class="chats-popup__stack">
105
+ <div class="chats-popup__slot chats-popup__slot--reactions"
106
+ data-chats--thread-target="popupReactions"
107
+ data-action="click->chats--thread#popupMenuClicked"></div>
108
+ <div class="chats-popup__slot chats-popup__slot--bubble" data-chats--thread-target="popupBubble"></div>
109
+ <div class="chats-popup__slot chats-popup__slot--menu"
110
+ data-chats--thread-target="popupMenu"
111
+ data-action="click->chats--thread#popupMenuClicked"></div>
112
+ </div>
113
+ </div>
114
+
115
+ <div class="chats-attachment-preview"
116
+ role="dialog"
117
+ aria-modal="true"
118
+ tabindex="-1"
119
+ data-chats--thread-target="attachmentDialog"
120
+ data-action="keydown.esc->chats--thread#closeAttachment"
121
+ hidden>
122
+ <div class="chats-attachment-preview__surface"
123
+ data-action="click->chats--thread#closeAttachmentFromBackdrop">
124
+ <button type="button"
125
+ class="chats-attachment-preview__close"
126
+ aria-label="<%= t("chats.message.close_attachment") %>"
127
+ data-action="chats--thread#closeAttachment">×</button>
128
+ <figure class="chats-attachment-preview__figure">
129
+ <img class="chats-attachment-preview__image"
130
+ alt=""
131
+ data-chats--thread-target="attachmentImage">
132
+ </figure>
133
+ </div>
134
+ </div>
135
+
136
+ <%= render "chats/messages/composer", conversation: @conversation %>
137
+ <% end %>
@@ -0,0 +1,67 @@
1
+ <%# The composer. Submits as a Turbo Stream (the sender's bubble appends from
2
+ the response — instant, no cable round-trip needed); the chats--composer
3
+ controller adds autosize, desktop Enter-to-send, throttled typing pings,
4
+ and reset-on-success. Plain form POST still works with JS disabled. %>
5
+ <%= form_with model: Chats::Message.new,
6
+ url: conversation_messages_path(conversation),
7
+ class: "chats-composer",
8
+ data: {
9
+ controller: "chats--composer",
10
+ "chats--composer-typing-url-value": (typing_conversation_path(conversation) if Chats.config.typing_indicators),
11
+ action: "turbo:submit-end->chats--composer#submitted chats:edit-message@window->chats--composer#beginEdit"
12
+ } do |form| %>
13
+
14
+ <div id="<%= dom_id(conversation, :composer_errors) %>" class="chats-composer__errors"></div>
15
+
16
+ <%# The edit cue (Telegram's flow): long-press → Editar loads the message
17
+ into this same form; this quote-style bar (left border, bold label,
18
+ the original trimmed to one line) is the signal that send = save.
19
+ The ✕ cancels back to compose mode. %>
20
+ <div class="chats-composer__edit" data-chats--composer-target="editBar" hidden>
21
+ <div class="chats-composer__edit-quote">
22
+ <span class="chats-composer__edit-label"><%= t("chats.composer.editing") %></span>
23
+ <span class="chats-composer__edit-preview" data-chats--composer-target="editPreview"></span>
24
+ </div>
25
+ <button type="button"
26
+ class="chats-composer__edit-cancel"
27
+ aria-label="<%= t("chats.composer.cancel_edit") %>"
28
+ data-action="chats--composer#cancelEdit">×</button>
29
+ </div>
30
+
31
+ <%# Selected-attachment previews: thumbnails with per-file remove, rendered
32
+ client-side from the picker selection (object URLs — nothing uploads
33
+ until send). This is THE feedback that files are about to go out; a
34
+ counter chip alone reads as noise. %>
35
+ <% if Chats.config.attachments %>
36
+ <div class="chats-composer__previews" data-chats--composer-target="previews" hidden></div>
37
+ <% end %>
38
+
39
+ <div class="chats-composer__bar">
40
+ <% if Chats.config.attachments %>
41
+ <label class="chats-composer__attach" aria-label="<%= t("chats.composer.attach") %>">
42
+ <%= form.file_field :files, multiple: true, hidden: true,
43
+ accept: (Chats.config.attachments == :images ? "image/*" : nil),
44
+ data: { "chats--composer-target": "files", action: "change->chats--composer#filesChanged" } %>
45
+ <span aria-hidden="true">📎</span>
46
+ <span class="chats-composer__file-count" data-chats--composer-target="fileCount" hidden></span>
47
+ </label>
48
+ <% end %>
49
+
50
+ <%= form.text_area :body,
51
+ rows: 1,
52
+ placeholder: t("chats.composer.placeholder"),
53
+ class: "chats-composer__input",
54
+ autocomplete: "off",
55
+ data: {
56
+ "chats--composer-target": "input",
57
+ action: "input->chats--composer#typed keydown.enter->chats--composer#enterSend"
58
+ } %>
59
+
60
+ <%# pointerdown/mousedown keepFocus: keeps the textarea focused (and the
61
+ mobile keyboard up) through the send tap — see composer controller. %>
62
+ <%= form.button type: :submit, class: "chats-composer__send", "aria-label": t("chats.composer.send"),
63
+ data: { action: "pointerdown->chats--composer#keepFocus mousedown->chats--composer#keepFocus" } do %>
64
+ <span aria-hidden="true">➤</span>
65
+ <% end %>
66
+ </div>
67
+ <% end %>
@@ -0,0 +1,158 @@
1
+ <%# One message bubble. CRITICAL CONSTRAINT: this partial renders ONCE per
2
+ broadcast and is delivered to EVERY subscriber, so nothing here may depend
3
+ on the viewer. Own-vs-other alignment, receipt state, and showing the
4
+ own-message actions menu are all decided CLIENT-SIDE by the chats--thread
5
+ controller comparing data-sender-key against its me-value (and CSS rules
6
+ on .chats-message--own). Server-side authorization still gates every
7
+ action — hiding is cosmetic, never security.
8
+
9
+ The wrapper id (dom_id) also makes Turbo Stream appends idempotent: when
10
+ the sender gets both the form response AND the broadcast, Turbo removes
11
+ the existing element with the same id before appending — no duplicates.
12
+ https://turbo.hotwired.dev/reference/streams %>
13
+ <div id="<%= dom_id(message) %>"
14
+ class="chats-message<%= " chats-message--system" if message.system? %><%= " chats-message--deleted" if message.deleted? %>"
15
+ data-chats--thread-target="message"
16
+ data-sender-key="<%= message.sender_key %>"
17
+ data-timestamp="<%= message.created_at.iso8601 %>"
18
+ <%# updated-at-ms: the thread controller tracks the max across bubbles as
19
+ its `?since=` cursor for the stale-thread refresh — updated_at (not
20
+ created_at) so edits/tombstones that happened while asleep are caught
21
+ too. `.ceil` biases the cursor PAST this row's sub-millisecond tail,
22
+ so the strictly-greater server query can't re-fetch it. %>
23
+ data-updated-at-ms="<%= (message.updated_at.to_f * 1000).ceil %>">
24
+
25
+ <% if message.system? %>
26
+ <div class="chats-message__system"><%= message.body %></div>
27
+ <% else %>
28
+ <div class="chats-message__content">
29
+ <% if message.conversation.group? && message.sender %>
30
+ <span class="chats-message__sender"><%= Chats.display_name_for(message.sender) %></span>
31
+ <% end %>
32
+
33
+ <div class="chats-message__bubble-row">
34
+ <%= chats_messager_avatar(message.sender, css_class: "chats-avatar chats-message__avatar") %>
35
+
36
+ <div class="chats-message__bubble">
37
+ <% if message.deleted? %>
38
+ <em class="chats-message__tombstone"><%= t("chats.message.deleted") %></em>
39
+ <% else %>
40
+ <% if message.body.present? %>
41
+ <%# Plain escaped text + white-space: pre-wrap (chats.css), NOT
42
+ simple_format: chat semantics are literal — every newline the
43
+ sender typed renders, including consecutive blank lines, which
44
+ simple_format collapses into a single paragraph break. ERB
45
+ escaping keeps user content from ever rendering as HTML. The
46
+ single marked node is the copy/edit contract. %>
47
+ <div class="chats-message__text" data-chats-message-body><%= message.body %></div>
48
+ <% end %>
49
+
50
+ <% if message.attachments? %>
51
+ <div class="chats-message__attachments">
52
+ <%# main_app.url_for, NOT bare url_for: Active Storage's routes
53
+ live in the MAIN app — inside this isolated engine (and in
54
+ the broadcast renderer) bare url_for would look for
55
+ attachment_path on the ENGINE's routes and blow up. %>
56
+ <% message.files.each do |file| %>
57
+ <% if file.image? %>
58
+ <%= link_to main_app.url_for(file),
59
+ class: "chats-message__image-link",
60
+ data: {
61
+ action: "chats--thread#openAttachment"
62
+ } do %>
63
+ <%# Thumbnails only when the host can actually transform:
64
+ `variable?` checks the content type, the ImageProcessing
65
+ check that the variant-processing gem is installed —
66
+ otherwise serving the variant URL would 500 later. %>
67
+ <% representable = file.variable? && defined?(ImageProcessing) %>
68
+ <%= image_tag main_app.url_for(representable ? file.variant(resize_to_limit: [480, 480]) : file),
69
+ class: "chats-message__image", loading: "lazy", alt: file.filename.to_s %>
70
+ <% end %>
71
+ <% else %>
72
+ <%= link_to "📄 #{file.filename}", main_app.url_for(file), target: "_blank", rel: "noopener", class: "chats-message__file" %>
73
+ <% end %>
74
+ <% end %>
75
+ </div>
76
+ <% end %>
77
+ <% end %>
78
+
79
+ <span class="chats-message__meta">
80
+ <% if message.edited? && !message.deleted? %>
81
+ <span class="chats-message__edited"><%= t("chats.message.edited") %></span>
82
+ <% end %>
83
+ <time datetime="<%= message.created_at.iso8601 %>"><%= message.created_at.in_time_zone.strftime("%H:%M") %></time>
84
+ <% if Chats.config.read_receipts %>
85
+ <span class="chats-message__receipt"
86
+ data-chats-message-receipt
87
+ aria-hidden="true"
88
+ hidden></span>
89
+ <span class="chats-visually-hidden"
90
+ data-chats-message-receipt-label
91
+ hidden></span>
92
+ <% end %>
93
+ </span>
94
+ </div>
95
+ </div>
96
+
97
+ <% unless message.deleted? %>
98
+ <% reactions = Chats.config.reactions ? Chats::Reaction.summary_for(message) : [] %>
99
+ <% if reactions.any? %>
100
+ <div class="chats-message__reactions">
101
+ <%# chats_routes (the mounted proxy), NOT bare engine helpers:
102
+ this partial is also rendered by Turbo broadcasts, which go
103
+ through the HOST's renderer where engine helpers don't exist
104
+ unqualified. See EngineHelper#chats_routes. %>
105
+ <% reactions.each do |emoji, count| %>
106
+ <%= button_to chats_routes.conversation_message_reactions_path(message.conversation, message),
107
+ method: :post, params: { emoji: emoji },
108
+ class: "chats-reaction", "aria-label": t("chats.message.toggle_reaction", emoji: emoji) do %>
109
+ <%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
110
+ <% end %>
111
+ <% end %>
112
+ </div>
113
+ <% end %>
114
+
115
+ <%# The long-press menu (Telegram-style). NOTHING actionable renders
116
+ inline anymore — everything lives in this inert <template>, which
117
+ the thread controller clones into the popup overlay on
118
+ long-press/right-click: the reactions pill lands above the
119
+ lifted bubble, this contextual menu below it. Items marked
120
+ data-chats-own-only are stripped from the clone for foreign
121
+ messages (cosmetic — the server still authorizes for real).
122
+ button_to forms keep working when cloned: the CSRF token is
123
+ baked in at render time. %>
124
+ <template data-chats-message-menu>
125
+ <% if Chats.config.reactions %>
126
+ <div class="chats-popup__reactions" role="group" aria-label="<%= t("chats.message.react") %>">
127
+ <% %w[👍 ❤️ 😂 😮 😢 🙏].each do |emoji| %>
128
+ <%= button_to emoji, chats_routes.conversation_message_reactions_path(message.conversation, message),
129
+ method: :post, params: { emoji: emoji }, class: "chats-popup__reaction" %>
130
+ <% end %>
131
+ </div>
132
+ <% end %>
133
+
134
+ <div class="chats-popup__menu" role="menu">
135
+ <% if message.body.present? %>
136
+ <button type="button" class="chats-popup__item" role="menuitem" data-chats-action="copy">
137
+ <%= t("chats.message.copy") %>
138
+ </button>
139
+ <% end %>
140
+ <% if Chats.config.editing && message.body.present? %>
141
+ <button type="button" class="chats-popup__item" role="menuitem" data-chats-action="edit" data-chats-own-only>
142
+ <%= t("chats.message.edit") %>
143
+ </button>
144
+ <% end %>
145
+ <% if Chats.config.deletion %>
146
+ <%= button_to t("chats.message.delete"),
147
+ chats_routes.conversation_message_path(message.conversation, message),
148
+ method: :delete,
149
+ class: "chats-popup__item chats-popup__item--danger",
150
+ role: "menuitem",
151
+ form: { data: { turbo_confirm: t("chats.message.delete_confirm"), chats_own_only: true } } %>
152
+ <% end %>
153
+ </div>
154
+ </template>
155
+ <% end %>
156
+ </div>
157
+ <% end %>
158
+ </div>
@@ -0,0 +1,6 @@
1
+ <%# Sender-side instant append (everyone else gets the broadcast; the sender
2
+ receives that too, where Turbo's same-id dedup turns it into a no-op). %>
3
+ <%= turbo_stream.append dom_id(@conversation, :messages) do %>
4
+ <%= render "chats/messages/message", message: @message %>
5
+ <% end %>
6
+ <%= turbo_stream.update dom_id(@conversation, :composer_errors), "" %>
@@ -0,0 +1,3 @@
1
+ <%= turbo_stream.update dom_id(@conversation, :composer_errors) do %>
2
+ <p class="chats-composer__error"><%= @message.errors.full_messages.to_sentence %></p>
3
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%# The live unread badge (embed anywhere via the chats_unread_badge helper).
2
+ Fixed id: it's the target Chats::Broadcasts.update_badge_of replaces.
3
+ Always rendered (hidden at zero) so the broadcast has an element to update. %>
4
+ <span id="chats_unread_badge"
5
+ class="chats-badge chats-badge--floating"
6
+ <%= "hidden" unless count.positive? %>><%= count > 99 ? "99+" : count %></span>
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pinned under "controllers/chats/..." ON PURPOSE: the stock Rails Stimulus
4
+ # setup (app/javascript/controllers/index.js) runs
5
+ # `eagerLoadControllersFrom("controllers", application)`, which scans the
6
+ # rendered importmap for ^controllers/.*_controller$ keys and registers each
7
+ # one, deriving identifiers from paths — these become "chats--thread",
8
+ # "chats--composer", etc. with ZERO host JavaScript changes. (stimulus-rails,
9
+ # app/assets/javascripts/stimulus-loading.js, registerControllerFromPath.)
10
+ #
11
+ # Hosts can override either controller by pinning the same key themselves —
12
+ # the engine's importmap is drawn FIRST (unshifted in Chats::Engine), and
13
+ # importmap-rails resolves duplicate pins last-wins.
14
+ pin "controllers/chats/thread_controller", to: "chats/thread_controller.js"
15
+ pin "controllers/chats/composer_controller", to: "chats/composer_controller.js"
16
+ pin "controllers/chats/debounced_submit_controller", to: "chats/debounced_submit_controller.js"
@@ -0,0 +1,87 @@
1
+ en:
2
+ chats:
3
+ inbox:
4
+ title: "Messages"
5
+ search_placeholder: "Search conversations"
6
+ empty_title: "No conversations yet"
7
+ empty_hint: "When you start a conversation, it will show up here."
8
+ no_results_title: "No results"
9
+ no_results_hint: "Nothing matched “%{query}”."
10
+ no_messages: "No messages yet"
11
+ you_prefix: "You:"
12
+ thread:
13
+ back: "Back"
14
+ options: "Conversation options"
15
+ mute: "Mute"
16
+ unmute: "Unmute"
17
+ leave: "Leave group"
18
+ loading: "Loading…"
19
+ sent: "Sent"
20
+ seen: "Seen"
21
+ today: "Today"
22
+ yesterday: "Yesterday"
23
+ typing_suffix: "is typing…"
24
+ new_messages: "New messages"
25
+ conversation:
26
+ empty_title: "Conversation"
27
+ message:
28
+ deleted: "Message deleted"
29
+ edited: "edited"
30
+ copy: "Copy"
31
+ copied: "Copied!"
32
+ attachment: "Photo"
33
+ close_attachment: "Close photo"
34
+ edit: "Edit"
35
+ delete: "Delete"
36
+ delete_confirm: "Delete this message for everyone?"
37
+ cancel: "Cancel"
38
+ save: "Save"
39
+ react: "React"
40
+ toggle_reaction: "React with %{emoji}"
41
+ composer:
42
+ editing: "Edit message"
43
+ cancel_edit: "Cancel edit"
44
+ placeholder: "Write a message…"
45
+ send: "Send"
46
+ attach: "Attach images"
47
+ buttons:
48
+ chat: "Message"
49
+ flashes:
50
+ blocked: "You can't message this person."
51
+ not_allowed: "You can't start this conversation."
52
+ left: "You left “%{title}”."
53
+ muted: "Conversation muted."
54
+ unmuted: "Conversation unmuted."
55
+
56
+ activerecord:
57
+ errors:
58
+ models:
59
+ chats/conversation:
60
+ attributes:
61
+ kind:
62
+ groups_disabled: "group conversations are disabled"
63
+ chats/participant:
64
+ attributes:
65
+ base:
66
+ group_full: "this group is full (max %{count} members)"
67
+ chats/message:
68
+ attributes:
69
+ base:
70
+ blocked: "You can't message this person."
71
+ sender:
72
+ blank: "is required"
73
+ not_a_participant: "is not a participant of this conversation"
74
+ body:
75
+ blank: "can't be empty"
76
+ too_long: "is too long (maximum %{count} characters)"
77
+ files:
78
+ not_allowed: "attachments are disabled"
79
+ too_many: "too many attachments (maximum %{count})"
80
+ must_be_images: "only images are allowed"
81
+ too_big: "files must be smaller than %{count} MB"
82
+ chats/reaction:
83
+ attributes:
84
+ base:
85
+ reactions_disabled: "reactions are disabled"
86
+ reactor:
87
+ not_a_participant: "is not a participant of this conversation"