chats 0.1.1 → 0.3.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 +4 -4
- data/CHANGELOG.md +233 -0
- data/README.md +214 -28
- data/app/assets/stylesheets/chats.css +86 -0
- data/app/controllers/chats/conversations_controller.rb +39 -42
- data/app/controllers/chats/messages_controller.rb +34 -0
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +113 -2
- data/app/javascript/chats/refresh_inbox_controller.js +86 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
- data/app/views/chats/conversations/_group.html.erb +41 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
- data/app/views/chats/conversations/index.html.erb +39 -9
- data/app/views/chats/conversations/show.html.erb +48 -3
- data/app/views/chats/messages/_composer.html.erb +4 -0
- data/app/views/chats/messages/_message.html.erb +29 -8
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +13 -0
- data/config/locales/es.yml +13 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/gemfiles/rails_7.1.gemfile +1 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/lib/chats/configuration.rb +79 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +303 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/macros.rb +33 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +99 -2
- data/lib/chats/models/conversation.rb +78 -7
- data/lib/chats/models/message.rb +67 -3
- data/lib/chats/models/participant.rb +59 -0
- data/lib/chats/models/reaction.rb +5 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +122 -15
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
- data/lib/generators/chats/templates/initializer.rb +106 -14
- data/lib/generators/chats/upgrade_generator.rb +48 -0
- metadata +13 -2
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
<%# The inbox. Subscribed to the viewer's inbox stream: new activity anywhere
|
|
2
2
|
triggers a Turbo 8 page refresh (morphing, scroll-preserving), which
|
|
3
3
|
re-renders this page per-viewer — see Chats::Broadcasts for why refreshes
|
|
4
|
-
beat surgical row patches here.
|
|
4
|
+
beat surgical row patches here.
|
|
5
|
+
|
|
6
|
+
Rows come from Chats::Inbox and are either a Chats::Conversation or a
|
|
7
|
+
Chats::InboxGroup (every direct thread with a `inbox: :grouped` messager,
|
|
8
|
+
stacked into one row).
|
|
9
|
+
|
|
10
|
+
The chats--refresh-inbox wrapper heals a MISSED refresh broadcast: Action
|
|
11
|
+
Cable has no replay, so a broadcast sent while the socket was down (tab
|
|
12
|
+
backgrounded, network blip) would leave the inbox stale until the user
|
|
13
|
+
navigated. The controller re-runs the same page refresh on cable reconnect
|
|
14
|
+
and on return-to-visible. It needs to wrap the <turbo-cable-stream-source>
|
|
15
|
+
that `turbo_stream_from` renders so it can observe its `connected`
|
|
16
|
+
attribute as the heartbeat. %>
|
|
5
17
|
<%= chats_styles %>
|
|
6
|
-
|
|
18
|
+
<div data-controller="chats--refresh-inbox">
|
|
19
|
+
<%= turbo_stream_from chats_current_messager, :chats_inbox %>
|
|
20
|
+
</div>
|
|
7
21
|
|
|
8
22
|
<div class="chats chats-inbox">
|
|
9
23
|
<header class="chats-inbox__header">
|
|
10
24
|
<h1 class="chats-inbox__title"><%= t("chats.inbox.title") %></h1>
|
|
11
25
|
</header>
|
|
12
26
|
|
|
13
|
-
<% if
|
|
27
|
+
<% if @inbox.filtered? %>
|
|
28
|
+
<p class="chats-inbox__filter">
|
|
29
|
+
<%= t("chats.inbox.filtered_by", name: Chats.display_name_for(@inbox.with)) %>
|
|
30
|
+
<%= link_to t("chats.inbox.clear_filter"), conversations_path, class: "chats-inbox__filter-clear" %>
|
|
31
|
+
</p>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<% if Chats.config.search && !@inbox.filtered? %>
|
|
14
35
|
<%= form_with url: conversations_path,
|
|
15
36
|
method: :get,
|
|
16
37
|
class: "chats-search",
|
|
@@ -29,13 +50,21 @@
|
|
|
29
50
|
<% end %>
|
|
30
51
|
|
|
31
52
|
<%= turbo_frame_tag "chats_inbox_results", target: "_top" do %>
|
|
32
|
-
|
|
53
|
+
<%# Slot: anything the host wants above the first row — a support door,
|
|
54
|
+
an announcement, a filter bar. Renders only when the partial exists. %>
|
|
55
|
+
<%= chats_slot :inbox_top, viewer: chats_current_messager, inbox: @inbox %>
|
|
56
|
+
|
|
57
|
+
<% if @rows.any? %>
|
|
33
58
|
<ul class="chats-inbox__list">
|
|
34
|
-
<% @
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
59
|
+
<% @rows.each do |row| %>
|
|
60
|
+
<% if row.is_a?(Chats::InboxGroup) %>
|
|
61
|
+
<%= render "chats/conversations/group", group: row, viewer: chats_current_messager %>
|
|
62
|
+
<% else %>
|
|
63
|
+
<%= render "chats/conversations/conversation_row",
|
|
64
|
+
conversation: row,
|
|
65
|
+
viewer: chats_current_messager,
|
|
66
|
+
unread_count: @unread_counts.fetch(row.id, 0) %>
|
|
67
|
+
<% end %>
|
|
39
68
|
<% end %>
|
|
40
69
|
</ul>
|
|
41
70
|
<% elsif params[:q].present? %>
|
|
@@ -48,6 +77,7 @@
|
|
|
48
77
|
<span class="chats-empty__icon" aria-hidden="true">💬</span>
|
|
49
78
|
<p class="chats-empty__title"><%= t("chats.inbox.empty_title") %></p>
|
|
50
79
|
<p class="chats-empty__hint"><%= t("chats.inbox.empty_hint") %></p>
|
|
80
|
+
<%= chats_slot :inbox_empty, viewer: chats_current_messager, inbox: @inbox %>
|
|
51
81
|
</div>
|
|
52
82
|
<% end %>
|
|
53
83
|
<% end %>
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"chats--thread-refresh-url-value": refresh_conversation_path(@conversation),
|
|
20
20
|
"chats--thread-thread-url-value": conversation_path(@conversation),
|
|
21
21
|
"chats--thread-copied-label-value": t("chats.message.copied"),
|
|
22
|
+
# A plain data attribute (not a Stimulus value): host CSS/JS keys its
|
|
23
|
+
# own block/report affordances off it. False for a messager declared
|
|
24
|
+
# `acts_as_messager blockable: false`.
|
|
25
|
+
"chats-blockable": chats_blockable?(chats_counterpart),
|
|
22
26
|
action: "pointerdown->chats--thread#pressStart pointermove->chats--thread#pressMove " \
|
|
23
27
|
"pointerup->chats--thread#pressEnd pointercancel->chats--thread#pressCancel " \
|
|
24
28
|
"contextmenu->chats--thread#contextMenu"
|
|
@@ -31,13 +35,37 @@
|
|
|
31
35
|
<span aria-hidden="true">‹</span>
|
|
32
36
|
<% end %>
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
<%# The counterpart is already resolved for the title, the profile link
|
|
39
|
+
and the stack link — reuse it here instead of asking again. %>
|
|
40
|
+
<% if chats_counterpart %>
|
|
41
|
+
<%= chats_messager_avatar(chats_counterpart) %>
|
|
42
|
+
<% else %>
|
|
43
|
+
<%= chats_conversation_avatar(@conversation, chats_current_messager) %>
|
|
44
|
+
<% end %>
|
|
35
45
|
|
|
36
46
|
<div class="chats-thread__identity">
|
|
37
|
-
|
|
47
|
+
<%# Both arms wrap the name in .chats-thread__name: the <h1> is a flex
|
|
48
|
+
row (the badge is its second child), and only a real element can
|
|
49
|
+
carry the ellipsis. %>
|
|
50
|
+
<h1 class="chats-thread__title">
|
|
51
|
+
<% if chats_counterpart %>
|
|
52
|
+
<%= chats_messager_name(chats_counterpart, css_class: "chats-thread__name") %>
|
|
53
|
+
<%= chats_verified_badge(chats_counterpart) %>
|
|
54
|
+
<% else %>
|
|
55
|
+
<span class="chats-thread__name"><%= @conversation.title_for(chats_current_messager) %></span>
|
|
56
|
+
<% end %>
|
|
57
|
+
</h1>
|
|
38
58
|
<% if @conversation.subject_label %>
|
|
39
59
|
<p class="chats-thread__subject"><%= @conversation.subject_label %></p>
|
|
40
60
|
<% end %>
|
|
61
|
+
<%# A stacked counterpart's thread is one of many: offer the way back
|
|
62
|
+
to the whole stack (chats' filtered inbox, or the host's own
|
|
63
|
+
screen via `group_path:`). %>
|
|
64
|
+
<% if Chats.grouped_inbox?(chats_counterpart) %>
|
|
65
|
+
<p class="chats-thread__see-all">
|
|
66
|
+
<%= link_to t("chats.thread.see_all"), chats_group_path_for(chats_counterpart), class: "chats-thread__see-all-link" %>
|
|
67
|
+
</p>
|
|
68
|
+
<% end %>
|
|
41
69
|
</div>
|
|
42
70
|
|
|
43
71
|
<details class="chats-menu">
|
|
@@ -51,6 +79,15 @@
|
|
|
51
79
|
<% if @conversation.group? %>
|
|
52
80
|
<%= button_to t("chats.thread.leave"), leave_conversation_path(@conversation), method: :post, class: "chats-menu__item chats-menu__item--danger" %>
|
|
53
81
|
<% end %>
|
|
82
|
+
<%# Slot: the host's own thread actions (block, report, archive…).
|
|
83
|
+
`blockable` is false for messagers declared
|
|
84
|
+
`acts_as_messager blockable: false`, so safety affordances
|
|
85
|
+
disappear against a support desk without any class check. %>
|
|
86
|
+
<%= chats_slot :conversation_header_actions,
|
|
87
|
+
conversation: @conversation,
|
|
88
|
+
viewer: chats_current_messager,
|
|
89
|
+
counterpart: chats_counterpart,
|
|
90
|
+
blockable: chats_blockable?(chats_counterpart) %>
|
|
54
91
|
</div>
|
|
55
92
|
</details>
|
|
56
93
|
</header>
|
|
@@ -133,5 +170,13 @@
|
|
|
133
170
|
</div>
|
|
134
171
|
</div>
|
|
135
172
|
|
|
136
|
-
|
|
173
|
+
<%# A locked conversation (its SUBJECT says so — Chats::ChatSubject#
|
|
174
|
+
chat_locked?) keeps its whole history readable and swaps the composer
|
|
175
|
+
for the reason it's closed. Never a hidden screen: gate the action,
|
|
176
|
+
explain it in place. %>
|
|
177
|
+
<% if @conversation.locked? %>
|
|
178
|
+
<%= render "chats/conversations/locked_composer", conversation: @conversation %>
|
|
179
|
+
<% else %>
|
|
180
|
+
<%= render "chats/messages/composer", conversation: @conversation %>
|
|
181
|
+
<% end %>
|
|
137
182
|
<% end %>
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
the response — instant, no cable round-trip needed); the chats--composer
|
|
3
3
|
controller adds autosize, desktop Enter-to-send, throttled typing pings,
|
|
4
4
|
and reset-on-success. Plain form POST still works with JS disabled. %>
|
|
5
|
+
<%# id: the swap target — a conversation that locks while this form is open
|
|
6
|
+
gets the locked notice rendered straight over it (see
|
|
7
|
+
chats/messages/locked.turbo_stream.erb). %>
|
|
5
8
|
<%= form_with model: Chats::Message.new,
|
|
6
9
|
url: conversation_messages_path(conversation),
|
|
10
|
+
id: dom_id(conversation, :composer),
|
|
7
11
|
class: "chats-composer",
|
|
8
12
|
data: {
|
|
9
13
|
controller: "chats--composer",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
<% else %>
|
|
28
28
|
<div class="chats-message__content">
|
|
29
29
|
<% if message.conversation.group? && message.sender %>
|
|
30
|
-
|
|
30
|
+
<%= chats_messager_name(message.sender, css_class: "chats-message__sender") %>
|
|
31
31
|
<% end %>
|
|
32
32
|
|
|
33
33
|
<div class="chats-message__bubble-row">
|
|
@@ -76,11 +76,20 @@
|
|
|
76
76
|
<% end %>
|
|
77
77
|
<% end %>
|
|
78
78
|
|
|
79
|
+
<%# Signature: who WROTE this, when that isn't who it was sent
|
|
80
|
+
from — an agent answering from a shared desk seat. The sender
|
|
81
|
+
stays the conversation identity; this line keeps the human
|
|
82
|
+
visible. See Chats::Message#signed?. %>
|
|
83
|
+
<% if (signature = chats_message_signature(message)) %>
|
|
84
|
+
<div class="chats-message__signature" data-chats-message-signature><%= signature %></div>
|
|
85
|
+
<% end %>
|
|
86
|
+
|
|
79
87
|
<span class="chats-message__meta">
|
|
80
88
|
<% if message.edited? && !message.deleted? %>
|
|
81
89
|
<span class="chats-message__edited"><%= t("chats.message.edited") %></span>
|
|
82
90
|
<% end %>
|
|
83
91
|
<time datetime="<%= message.created_at.iso8601 %>"><%= message.created_at.in_time_zone.strftime("%H:%M") %></time>
|
|
92
|
+
<%= chats_slot :message_meta, message: message %>
|
|
84
93
|
<% if Chats.config.read_receipts %>
|
|
85
94
|
<span class="chats-message__receipt"
|
|
86
95
|
data-chats-message-receipt
|
|
@@ -95,6 +104,12 @@
|
|
|
95
104
|
</div>
|
|
96
105
|
|
|
97
106
|
<% unless message.deleted? %>
|
|
107
|
+
<%# A locked conversation (its SUBJECT says so) refuses every write —
|
|
108
|
+
new messages, edits, deletes and reactions alike. The server
|
|
109
|
+
enforces it; here we stop OFFERING what would only 422: existing
|
|
110
|
+
reactions still render, as plain counts instead of toggles, and
|
|
111
|
+
the long-press menu keeps only Copy. %>
|
|
112
|
+
<% locked = message.conversation.locked? %>
|
|
98
113
|
<% reactions = Chats.config.reactions ? Chats::Reaction.summary_for(message) : [] %>
|
|
99
114
|
<% if reactions.any? %>
|
|
100
115
|
<div class="chats-message__reactions">
|
|
@@ -103,10 +118,16 @@
|
|
|
103
118
|
through the HOST's renderer where engine helpers don't exist
|
|
104
119
|
unqualified. See EngineHelper#chats_routes. %>
|
|
105
120
|
<% reactions.each do |emoji, count| %>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
<% if locked %>
|
|
122
|
+
<span class="chats-reaction chats-reaction--locked">
|
|
123
|
+
<%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
|
|
124
|
+
</span>
|
|
125
|
+
<% else %>
|
|
126
|
+
<%= button_to chats_routes.conversation_message_reactions_path(message.conversation, message),
|
|
127
|
+
method: :post, params: { emoji: emoji },
|
|
128
|
+
class: "chats-reaction", "aria-label": t("chats.message.toggle_reaction", emoji: emoji) do %>
|
|
129
|
+
<%= emoji %><% if count > 1 %><span class="chats-reaction__count"><%= count %></span><% end %>
|
|
130
|
+
<% end %>
|
|
110
131
|
<% end %>
|
|
111
132
|
<% end %>
|
|
112
133
|
</div>
|
|
@@ -122,7 +143,7 @@
|
|
|
122
143
|
button_to forms keep working when cloned: the CSRF token is
|
|
123
144
|
baked in at render time. %>
|
|
124
145
|
<template data-chats-message-menu>
|
|
125
|
-
<% if Chats.config.reactions %>
|
|
146
|
+
<% if Chats.config.reactions && !locked %>
|
|
126
147
|
<div class="chats-popup__reactions" role="group" aria-label="<%= t("chats.message.react") %>">
|
|
127
148
|
<% %w[👍 ❤️ 😂 😮 😢 🙏].each do |emoji| %>
|
|
128
149
|
<%= button_to emoji, chats_routes.conversation_message_reactions_path(message.conversation, message),
|
|
@@ -137,12 +158,12 @@
|
|
|
137
158
|
<%= t("chats.message.copy") %>
|
|
138
159
|
</button>
|
|
139
160
|
<% end %>
|
|
140
|
-
<% if Chats.config.editing && message.body.present? %>
|
|
161
|
+
<% if Chats.config.editing && message.body.present? && !locked %>
|
|
141
162
|
<button type="button" class="chats-popup__item" role="menuitem" data-chats-action="edit" data-chats-own-only>
|
|
142
163
|
<%= t("chats.message.edit") %>
|
|
143
164
|
</button>
|
|
144
165
|
<% end %>
|
|
145
|
-
<% if Chats.config.deletion %>
|
|
166
|
+
<% if Chats.config.deletion && !locked %>
|
|
146
167
|
<%= button_to t("chats.message.delete"),
|
|
147
168
|
chats_routes.conversation_message_path(message.conversation, message),
|
|
148
169
|
method: :delete,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# The send landed on a conversation whose subject has since locked it.
|
|
2
|
+
Replace the composer with the locked notice (422) so the screen stops
|
|
3
|
+
lying to whoever had it open, and clear any stale error text. %>
|
|
4
|
+
<%= turbo_stream.replace dom_id(@conversation, :composer) do %>
|
|
5
|
+
<%= render "chats/conversations/locked_composer", conversation: @conversation %>
|
|
6
|
+
<% end %>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%# The "official account" mark. Rendered next to a verified messager's name
|
|
2
|
+
(`acts_as_messager verified: true`) in every bundled view that shows one:
|
|
3
|
+
the inbox row, the stacked row and the thread header.
|
|
4
|
+
|
|
5
|
+
It is an IMAGE with a name, not decoration: the wrapper carries
|
|
6
|
+
role="img" plus the localized label, so a screen reader announces
|
|
7
|
+
"Cuenta oficial" / "Official account" where a sighted person sees the
|
|
8
|
+
badge, and the glyph itself is aria-hidden so it is never read twice.
|
|
9
|
+
|
|
10
|
+
The glyph is heroicons `check-badge` (24/solid): a scalloped rosette
|
|
11
|
+
with a TICK knocked out of it, which is the mark people already read as
|
|
12
|
+
"verified". It is deliberately not a plain star — a star says "favourite"
|
|
13
|
+
or "rated", and at 14px a solid star and a solid rosette are the same
|
|
14
|
+
blob, so the tick is the whole point. `fill-rule: evenodd` is what knocks
|
|
15
|
+
the tick out; without it the rosette fills solid and the mark is a blob.
|
|
16
|
+
|
|
17
|
+
Colour comes from the --chats-verified CSS variable (see chats.css) via
|
|
18
|
+
`fill: currentColor` — override that variable, or swap this whole partial
|
|
19
|
+
with `config.verified_badge`. %>
|
|
20
|
+
<span class="chats-verified"
|
|
21
|
+
role="img"
|
|
22
|
+
title="<%= t("chats.verified.label") %>"
|
|
23
|
+
aria-label="<%= t("chats.verified.label") %>">
|
|
24
|
+
<svg xmlns="http://www.w3.org/2000/svg"
|
|
25
|
+
viewBox="0 0 24 24"
|
|
26
|
+
fill="currentColor"
|
|
27
|
+
class="chats-verified__glyph"
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
focusable="false">
|
|
30
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" />
|
|
31
|
+
</svg>
|
|
32
|
+
</span>
|
data/config/importmap.rb
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
# "chats--composer", etc. with ZERO host JavaScript changes. (stimulus-rails,
|
|
9
9
|
# app/assets/javascripts/stimulus-loading.js, registerControllerFromPath.)
|
|
10
10
|
#
|
|
11
|
-
# Hosts can override
|
|
11
|
+
# Hosts can override any controller by pinning the same key themselves —
|
|
12
12
|
# the engine's importmap is drawn FIRST (unshifted in Chats::Engine), and
|
|
13
13
|
# importmap-rails resolves duplicate pins last-wins.
|
|
14
14
|
pin "controllers/chats/thread_controller", to: "chats/thread_controller.js"
|
|
15
15
|
pin "controllers/chats/composer_controller", to: "chats/composer_controller.js"
|
|
16
16
|
pin "controllers/chats/debounced_submit_controller", to: "chats/debounced_submit_controller.js"
|
|
17
|
+
pin "controllers/chats/refresh_inbox_controller", to: "chats/refresh_inbox_controller.js"
|
data/config/locales/en.yml
CHANGED
|
@@ -8,6 +8,11 @@ en:
|
|
|
8
8
|
no_results_title: "No results"
|
|
9
9
|
no_results_hint: "Nothing matched “%{query}”."
|
|
10
10
|
no_messages: "No messages yet"
|
|
11
|
+
filtered_by: "Conversations with %{name}"
|
|
12
|
+
clear_filter: "Show all conversations"
|
|
13
|
+
group_count:
|
|
14
|
+
one: "1 conversation"
|
|
15
|
+
other: "%{count} conversations"
|
|
11
16
|
you_prefix: "You:"
|
|
12
17
|
thread:
|
|
13
18
|
back: "Back"
|
|
@@ -22,14 +27,18 @@ en:
|
|
|
22
27
|
yesterday: "Yesterday"
|
|
23
28
|
typing_suffix: "is typing…"
|
|
24
29
|
new_messages: "New messages"
|
|
30
|
+
see_all: "See all"
|
|
25
31
|
conversation:
|
|
26
32
|
empty_title: "Conversation"
|
|
33
|
+
verified:
|
|
34
|
+
label: "Official account"
|
|
27
35
|
message:
|
|
28
36
|
deleted: "Message deleted"
|
|
29
37
|
edited: "edited"
|
|
30
38
|
copy: "Copy"
|
|
31
39
|
copied: "Copied!"
|
|
32
40
|
attachment: "Photo"
|
|
41
|
+
signature: "— %{name}"
|
|
33
42
|
close_attachment: "Close photo"
|
|
34
43
|
edit: "Edit"
|
|
35
44
|
delete: "Delete"
|
|
@@ -44,6 +53,7 @@ en:
|
|
|
44
53
|
placeholder: "Write a message…"
|
|
45
54
|
send: "Send"
|
|
46
55
|
attach: "Attach images"
|
|
56
|
+
locked: "This conversation is closed."
|
|
47
57
|
buttons:
|
|
48
58
|
chat: "Message"
|
|
49
59
|
flashes:
|
|
@@ -67,7 +77,10 @@ en:
|
|
|
67
77
|
chats/message:
|
|
68
78
|
attributes:
|
|
69
79
|
base:
|
|
80
|
+
locked: "This conversation is closed."
|
|
70
81
|
blocked: "You can't message this person."
|
|
82
|
+
author:
|
|
83
|
+
not_a_messager: "must be a messager (acts_as_messager)"
|
|
71
84
|
sender:
|
|
72
85
|
blank: "is required"
|
|
73
86
|
not_a_participant: "is not a participant of this conversation"
|
data/config/locales/es.yml
CHANGED
|
@@ -8,6 +8,11 @@ es:
|
|
|
8
8
|
no_results_title: "Sin resultados"
|
|
9
9
|
no_results_hint: "No hay nada que coincida con «%{query}»."
|
|
10
10
|
no_messages: "Sin mensajes todavía"
|
|
11
|
+
filtered_by: "Conversaciones con %{name}"
|
|
12
|
+
clear_filter: "Ver todas las conversaciones"
|
|
13
|
+
group_count:
|
|
14
|
+
one: "1 conversación"
|
|
15
|
+
other: "%{count} conversaciones"
|
|
11
16
|
you_prefix: "Tú:"
|
|
12
17
|
thread:
|
|
13
18
|
back: "Atrás"
|
|
@@ -22,14 +27,18 @@ es:
|
|
|
22
27
|
yesterday: "Ayer"
|
|
23
28
|
typing_suffix: "está escribiendo…"
|
|
24
29
|
new_messages: "Mensajes nuevos"
|
|
30
|
+
see_all: "Ver todas"
|
|
25
31
|
conversation:
|
|
26
32
|
empty_title: "Conversación"
|
|
33
|
+
verified:
|
|
34
|
+
label: "Cuenta oficial"
|
|
27
35
|
message:
|
|
28
36
|
deleted: "Mensaje eliminado"
|
|
29
37
|
edited: "editado"
|
|
30
38
|
copy: "Copiar"
|
|
31
39
|
copied: "¡Copiado!"
|
|
32
40
|
attachment: "Foto"
|
|
41
|
+
signature: "— %{name}"
|
|
33
42
|
close_attachment: "Cerrar foto"
|
|
34
43
|
edit: "Editar"
|
|
35
44
|
delete: "Eliminar"
|
|
@@ -44,6 +53,7 @@ es:
|
|
|
44
53
|
placeholder: "Escribe un mensaje…"
|
|
45
54
|
send: "Enviar"
|
|
46
55
|
attach: "Adjuntar imágenes"
|
|
56
|
+
locked: "Esta conversación está cerrada."
|
|
47
57
|
buttons:
|
|
48
58
|
chat: "Mensaje"
|
|
49
59
|
flashes:
|
|
@@ -67,7 +77,10 @@ es:
|
|
|
67
77
|
chats/message:
|
|
68
78
|
attributes:
|
|
69
79
|
base:
|
|
80
|
+
locked: "Esta conversación está cerrada."
|
|
70
81
|
blocked: "No puedes enviar mensajes a esta persona."
|
|
82
|
+
author:
|
|
83
|
+
not_a_messager: "debe ser un mensajero (acts_as_messager)"
|
|
71
84
|
sender:
|
|
72
85
|
blank: "es obligatorio"
|
|
73
86
|
not_a_participant: "no participa en esta conversación"
|
data/context7.json
ADDED
data/docs/PRD.md
CHANGED
|
@@ -18,7 +18,7 @@ Direct messages, group chats, reactions, attachments, read receipts — Hotwire-
|
|
|
18
18
|
|
|
19
19
|
A gem you add to a Rails app to get **Instagram/X-DM-class** user-to-user messaging without building it again. The happy path is one generator + one `acts_as_messager` line + a mounted engine; the result is a working, real-time, polished inbox. Power users override views, policies, and adapters.
|
|
20
20
|
|
|
21
|
-
It is **not** a chatbot/LLM framework, not a Slack-clone with workspaces, and not a support-ticketing tool. It is peer-to-peer (and group) human messaging.
|
|
21
|
+
It is **not** a chatbot/LLM framework, not a Slack-clone with workspaces, and not a support-ticketing tool (that is `support_desk`, a separate product gem built on this one — see README). It is peer-to-peer (and group) human messaging.
|
|
22
22
|
|
|
23
23
|
**Why it exists:** every consumer app eventually needs DMs, and everyone rebuilds the same Conversation/Message/Participant/Receipt model, the same Action Cable + Turbo plumbing, and the same "report this message / block this user / filter this text" surface. We already built the moderation half for CarHey; `chats` is the messaging half, and the two snap together.
|
|
24
24
|
|
data/docs/campfire_review.md
CHANGED
|
@@ -11,7 +11,7 @@ with the same facts.
|
|
|
11
11
|
|
|
12
12
|
| Campfire pattern | Where it landed here | Notes |
|
|
13
13
|
| --- | --- | --- |
|
|
14
|
-
| **Stale-room refresh** (`Rooms::RefreshesController` + `refresh_room_controller.js`): on tab-visible-after-sleep or cable reconnect, fetch `?since=` and append new / replace updated | `ConversationsController#refresh`, `Message.created_since/.updated_since`, thread controller `refreshThread()` | The single biggest reliability pattern in their codebase — mobile WebViews reap WebSockets constantly. We improved the trigger: instead of their dedicated `HeartbeatChannel`, we observe the `connected` attribute turbo-rails already toggles on `<turbo-cable-stream-source>`. Zero new channels. We also added a deep-backlog escape hatch: > 1 page missed answers with a Turbo 8 `refresh` stream action (full morph) instead of splicing arbitrary history. |
|
|
14
|
+
| **Stale-room refresh** (`Rooms::RefreshesController` + `refresh_room_controller.js`): on tab-visible-after-sleep or cable reconnect, fetch `?since=` and append new / replace updated | `ConversationsController#refresh`, `Message.created_since/.updated_since`, thread controller `refreshThread()`; **and the inbox** via `refresh_inbox_controller.js` (same reconnect/visibility triggers, but the recovery action is a Turbo 8 page `refresh` since inbox broadcasts already are page refreshes) | The single biggest reliability pattern in their codebase — mobile WebViews reap WebSockets constantly. We improved the trigger: instead of their dedicated `HeartbeatChannel`, we observe the `connected` attribute turbo-rails already toggles on `<turbo-cable-stream-source>`. Zero new channels. We also added a deep-backlog escape hatch: > 1 page missed answers with a Turbo 8 `refresh` stream action (full morph) instead of splicing arbitrary history. The inbox got the same doctrine (missed inbox refreshes had no recovery before). |
|
|
15
15
|
| **DOM cap** (`message_paginator.js` `maxMessages: 300`) | Thread controller `trimExcessMessages()` (300 + 20 leeway) | Only trims while the viewer is parked at the bottom. Our pagination is a server-rendered lazy-frame chain (theirs is JS-driven), so trimming also re-plants the keyset anchor frame (`rebuildPaginationAnchor()`) — trimmed history stays reachable on scroll-up with no gaps. |
|
|
16
16
|
| **Out-of-order arrival handling** (their `messages_controller.js` re-sorts on insert) | Thread controller `ensureChronological()` | Broadcast appends from concurrent host job workers can land out of order. ISO8601 lexicographic compare; equal timestamps keep arrival order. |
|
|
17
17
|
| **First-unread anchoring** (they page around the first unread; membership unread marker) | The «new messages» divider: `@first_unread_id` computed in `#show` *before* `read!` advances the horizon | We render a divider rather than re-anchoring the page — the thread still opens at the bottom (coordination chats are short; jumping deep into history on open would feel broken at our scale). Backlogs deeper than a page pin the divider to the top of the page. |
|
data/gemfiles/rails_7.1.gemfile
CHANGED
data/gemfiles/rails_7.2.gemfile
CHANGED
data/gemfiles/rails_8.1.gemfile
CHANGED
data/lib/chats/configuration.rb
CHANGED
|
@@ -86,6 +86,12 @@ module Chats
|
|
|
86
86
|
attr_accessor :messages_per_page, :max_message_length, :max_group_size, :max_attachment_size,
|
|
87
87
|
:max_attachments_per_message
|
|
88
88
|
|
|
89
|
+
# How many conversations the inbox loads (and therefore how deep search
|
|
90
|
+
# and grouping see). The inbox is a "recent activity" surface, not an
|
|
91
|
+
# archive — raise it only if your users really keep hundreds of live
|
|
92
|
+
# threads.
|
|
93
|
+
attr_accessor :inbox_limit
|
|
94
|
+
|
|
89
95
|
# Per-sender send throttle, enforced with Rails 8's built-in controller
|
|
90
96
|
# `rate_limit` when available (feature-detected; on Rails 7.1 it's a
|
|
91
97
|
# no-op). Shape: `{ to: Integer, within: ActiveSupport::Duration }`.
|
|
@@ -109,6 +115,12 @@ module Chats
|
|
|
109
115
|
# May +creator+ create a group conversation?
|
|
110
116
|
attr_reader :can_create_group
|
|
111
117
|
|
|
118
|
+
# ->(relation, viewer) { relation } — composed into the inbox query
|
|
119
|
+
# before the limit, so a host can hide or re-scope rows without
|
|
120
|
+
# overriding the controller:
|
|
121
|
+
# config.inbox_scope = ->(relation, viewer) { relation.where.not(kind: "group") }
|
|
122
|
+
attr_reader :inbox_scope
|
|
123
|
+
|
|
112
124
|
# --- Ecosystem seams (procs, no-op defaults) ------------------------------
|
|
113
125
|
|
|
114
126
|
# ->(messager) { ids } — every messager id that can't talk with the given
|
|
@@ -130,6 +142,28 @@ module Chats
|
|
|
130
142
|
# or variant), or nil to render an initials placeholder.
|
|
131
143
|
attr_reader :messager_avatar
|
|
132
144
|
|
|
145
|
+
# ->(messager) { path_or_url_or_nil } — where a messager's profile lives.
|
|
146
|
+
# The bundled views link names and avatars to it; nil (the default) means
|
|
147
|
+
# no anchor at all, so the gem never assumes a `user_path` exists.
|
|
148
|
+
attr_reader :messager_url
|
|
149
|
+
|
|
150
|
+
# ->(message) { String } — the signature line under a SIGNED message
|
|
151
|
+
# (one written by an author on the sender's behalf — see
|
|
152
|
+
# Chats::Message#signed?). nil (the default) renders the localized
|
|
153
|
+
# "— Author Name".
|
|
154
|
+
attr_reader :message_signature
|
|
155
|
+
|
|
156
|
+
# ->(messager) { markup } — the badge shown next to an OFFICIAL
|
|
157
|
+
# account's name (`acts_as_messager verified: true`). nil (the default)
|
|
158
|
+
# renders the gem's own rosette, `chats/shared/_verified_badge`, coloured
|
|
159
|
+
# by the `--chats-verified` CSS variable.
|
|
160
|
+
#
|
|
161
|
+
# Return an html_safe value (anything `render`, `tag` or `image_tag`
|
|
162
|
+
# gives you) — a bare String is escaped, exactly as it would be anywhere
|
|
163
|
+
# else in a Rails view. Returning nil renders nothing, so a host can
|
|
164
|
+
# suppress the badge on some messagers without touching the models.
|
|
165
|
+
attr_reader :verified_badge
|
|
166
|
+
|
|
133
167
|
def initialize
|
|
134
168
|
@messager_class = "User"
|
|
135
169
|
@parent_controller = "::ApplicationController"
|
|
@@ -152,14 +186,19 @@ module Chats
|
|
|
152
186
|
@max_attachment_size = 10 * 1024 * 1024 # 10 MB
|
|
153
187
|
@max_attachments_per_message = 4
|
|
154
188
|
@send_rate_limit = { to: 60, within: 60 } # 60 messages per minute per sender
|
|
189
|
+
@inbox_limit = 200
|
|
155
190
|
|
|
156
191
|
@encrypt_messages = false
|
|
157
192
|
|
|
158
193
|
@can_message = ->(_sender, _recipient) { true }
|
|
159
194
|
@can_create_group = ->(_creator) { true }
|
|
195
|
+
@inbox_scope = ->(relation, _viewer) { relation }
|
|
160
196
|
|
|
161
197
|
@blocked_messager_ids = ->(_messager) { [] }
|
|
162
198
|
@notifier = ->(_event, **_payload) {}
|
|
199
|
+
@messager_url = ->(_messager) { nil }
|
|
200
|
+
@message_signature = nil
|
|
201
|
+
@verified_badge = nil
|
|
163
202
|
|
|
164
203
|
@messager_display_name = lambda do |messager|
|
|
165
204
|
messager.try(:display_name) || messager.try(:name) ||
|
|
@@ -232,8 +271,45 @@ module Chats
|
|
|
232
271
|
@blocked_messager_ids = ensure_callable(value, "blocked_messager_ids")
|
|
233
272
|
end
|
|
234
273
|
|
|
274
|
+
# DEPRECATED (removed in 1.0): sugar that subscribes one `(event,
|
|
275
|
+
# **payload)` proc to the two events that existed in 0.1.1
|
|
276
|
+
# (:message_created, :conversation_read) — and ONLY those, so an old
|
|
277
|
+
# keyword-specific hook can't start raising on events it was never
|
|
278
|
+
# written for. `Chats.on` supersedes it: many subscribers, per-event
|
|
279
|
+
# payloads, reload-safe keys, and every event.
|
|
235
280
|
def notifier=(value)
|
|
236
|
-
|
|
281
|
+
hook = ensure_callable(value, "notifier")
|
|
282
|
+
Chats.deprecator.warn(
|
|
283
|
+
"config.notifier is deprecated and will be removed in chats 1.0. " \
|
|
284
|
+
"It receives #{Chats::Subscribers::LEGACY_NOTIFIER_EVENTS.map(&:inspect).join(" and ")} only; " \
|
|
285
|
+
"the events added in 0.2.0 are Chats.on-only. " \
|
|
286
|
+
"Subscribe with Chats.on(:message_created) { |message| … } instead " \
|
|
287
|
+
"(see the README's \"Events\" section)."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
Chats::Subscribers::LEGACY_NOTIFIER_EVENTS.each do |event|
|
|
291
|
+
Chats::Subscribers.on(event, key: Chats::Subscribers::NOTIFIER_KEY, style: :event) do |fired, **payload|
|
|
292
|
+
hook.call(fired, **payload)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
@notifier = hook
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def inbox_scope=(value)
|
|
300
|
+
@inbox_scope = ensure_callable(value, "inbox_scope")
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def messager_url=(value)
|
|
304
|
+
@messager_url = ensure_callable(value, "messager_url")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def message_signature=(value)
|
|
308
|
+
@message_signature = value.nil? ? nil : ensure_callable(value, "message_signature")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def verified_badge=(value)
|
|
312
|
+
@verified_badge = value.nil? ? nil : ensure_callable(value, "verified_badge")
|
|
237
313
|
end
|
|
238
314
|
|
|
239
315
|
def messager_display_name=(value)
|
|
@@ -259,6 +335,8 @@ module Chats
|
|
|
259
335
|
raise ConfigurationError, "messages_per_page must be positive (got #{messages_per_page.inspect})"
|
|
260
336
|
end
|
|
261
337
|
|
|
338
|
+
raise ConfigurationError, "inbox_limit must be positive (got #{inbox_limit.inspect})" if inbox_limit.to_i < 1
|
|
339
|
+
|
|
262
340
|
true
|
|
263
341
|
end
|
|
264
342
|
|