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.
- checksums.yaml +7 -0
- data/.rubocop.yml +43 -0
- data/.simplecov +52 -0
- data/AGENTS.md +5 -0
- data/Appraisals +17 -0
- data/CHANGELOG.md +74 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/chats.css +818 -0
- data/app/controllers/chats/application_controller.rb +65 -0
- data/app/controllers/chats/conversations_controller.rb +198 -0
- data/app/controllers/chats/messages_controller.rb +118 -0
- data/app/controllers/chats/reactions_controller.rb +33 -0
- data/app/helpers/chats/engine_helper.rb +212 -0
- data/app/javascript/chats/composer_controller.js +258 -0
- data/app/javascript/chats/debounced_submit_controller.js +40 -0
- data/app/javascript/chats/thread_controller.js +855 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
- data/app/views/chats/conversations/_messages_page.html.erb +16 -0
- data/app/views/chats/conversations/_read_state.html.erb +11 -0
- data/app/views/chats/conversations/index.html.erb +54 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
- data/app/views/chats/conversations/show.html.erb +137 -0
- data/app/views/chats/messages/_composer.html.erb +67 -0
- data/app/views/chats/messages/_message.html.erb +158 -0
- data/app/views/chats/messages/create.turbo_stream.erb +6 -0
- data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
- data/app/views/chats/shared/_unread_badge.html.erb +6 -0
- data/config/importmap.rb +16 -0
- data/config/locales/en.yml +87 -0
- data/config/locales/es.yml +87 -0
- data/config/routes.rb +24 -0
- data/docs/PRD.md +254 -0
- data/docs/campfire_review.md +46 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/chats/broadcasts.rb +147 -0
- data/lib/chats/configuration.rb +286 -0
- data/lib/chats/engine.rb +146 -0
- data/lib/chats/errors.rb +20 -0
- data/lib/chats/macros.rb +28 -0
- data/lib/chats/models/application_record.rb +11 -0
- data/lib/chats/models/concerns/chat_subject.rb +35 -0
- data/lib/chats/models/concerns/messager.rb +102 -0
- data/lib/chats/models/conversation.rb +347 -0
- data/lib/chats/models/message.rb +323 -0
- data/lib/chats/models/participant.rb +151 -0
- data/lib/chats/models/reaction.rb +70 -0
- data/lib/chats/version.rb +5 -0
- data/lib/chats.rb +188 -0
- data/lib/generators/chats/install_generator.rb +62 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
- data/lib/generators/chats/templates/initializer.rb +138 -0
- data/lib/generators/chats/views_generator.rb +49 -0
- 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,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>
|
data/config/importmap.rb
ADDED
|
@@ -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"
|