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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +233 -0
  3. data/README.md +214 -28
  4. data/app/assets/stylesheets/chats.css +86 -0
  5. data/app/controllers/chats/conversations_controller.rb +39 -42
  6. data/app/controllers/chats/messages_controller.rb +34 -0
  7. data/app/controllers/chats/reactions_controller.rb +14 -0
  8. data/app/helpers/chats/engine_helper.rb +113 -2
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
  11. data/app/views/chats/conversations/_group.html.erb +41 -0
  12. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  13. data/app/views/chats/conversations/index.html.erb +39 -9
  14. data/app/views/chats/conversations/show.html.erb +48 -3
  15. data/app/views/chats/messages/_composer.html.erb +4 -0
  16. data/app/views/chats/messages/_message.html.erb +29 -8
  17. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  18. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  19. data/config/importmap.rb +2 -1
  20. data/config/locales/en.yml +13 -0
  21. data/config/locales/es.yml +13 -0
  22. data/context7.json +4 -0
  23. data/docs/PRD.md +1 -1
  24. data/docs/campfire_review.md +1 -1
  25. data/gemfiles/rails_7.1.gemfile +1 -0
  26. data/gemfiles/rails_7.2.gemfile +1 -0
  27. data/gemfiles/rails_8.1.gemfile +1 -0
  28. data/lib/chats/configuration.rb +79 -1
  29. data/lib/chats/engine.rb +29 -7
  30. data/lib/chats/errors.rb +16 -0
  31. data/lib/chats/inbox.rb +303 -0
  32. data/lib/chats/inbox_group.rb +75 -0
  33. data/lib/chats/macros.rb +33 -1
  34. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  35. data/lib/chats/models/concerns/messager.rb +99 -2
  36. data/lib/chats/models/conversation.rb +78 -7
  37. data/lib/chats/models/message.rb +67 -3
  38. data/lib/chats/models/participant.rb +59 -0
  39. data/lib/chats/models/reaction.rb +5 -0
  40. data/lib/chats/subscribers.rb +156 -0
  41. data/lib/chats/version.rb +1 -1
  42. data/lib/chats.rb +122 -15
  43. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  44. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  45. data/lib/generators/chats/templates/initializer.rb +106 -14
  46. data/lib/generators/chats/upgrade_generator.rb +48 -0
  47. 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
- <%= turbo_stream_from chats_current_messager, :chats_inbox %>
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 Chats.config.search %>
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
- <% if @conversations.any? %>
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
- <% @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) %>
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
- <%= chats_conversation_avatar(@conversation, chats_current_messager) %>
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
- <h1 class="chats-thread__title"><%= @conversation.title_for(chats_current_messager) %></h1>
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
- <%= render "chats/messages/composer", conversation: @conversation %>
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
- <span class="chats-message__sender"><%= Chats.display_name_for(message.sender) %></span>
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
- <%= 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 %>
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 either controller by pinning the same key themselves —
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"
@@ -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"
@@ -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
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/chats",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }
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
 
@@ -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. |
@@ -14,6 +14,7 @@ group :development do
14
14
  end
15
15
 
16
16
  group :test do
17
+ gem "json", "~> 2.7"
17
18
  gem "minitest", "~> 6.0"
18
19
  gem "minitest-mock"
19
20
  gem "mocha", "~> 2.0"
@@ -14,6 +14,7 @@ group :development do
14
14
  end
15
15
 
16
16
  group :test do
17
+ gem "json", "~> 2.7"
17
18
  gem "minitest", "~> 6.0"
18
19
  gem "minitest-mock"
19
20
  gem "mocha", "~> 2.0"
@@ -14,6 +14,7 @@ group :development do
14
14
  end
15
15
 
16
16
  group :test do
17
+ gem "json", "~> 2.7"
17
18
  gem "minitest", "~> 6.0"
18
19
  gem "minitest-mock"
19
20
  gem "mocha", "~> 2.0"
@@ -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
- @notifier = ensure_callable(value, "notifier")
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