chats 0.4.2 → 0.5.0

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +247 -0
  3. data/README.md +95 -8
  4. data/app/assets/stylesheets/chats.css +596 -0
  5. data/app/controllers/chats/conversations_controller.rb +71 -5
  6. data/app/helpers/chats/engine_helper.rb +75 -2
  7. data/app/javascript/chats/composer_controller.js +14 -1
  8. data/app/javascript/chats/dock_controller.js +1070 -0
  9. data/app/javascript/chats/thread_controller.js +72 -6
  10. data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
  11. data/app/views/chats/conversations/_group.html.erb +6 -2
  12. data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
  13. data/app/views/chats/conversations/_thread.html.erb +172 -0
  14. data/app/views/chats/conversations/dock.html.erb +40 -0
  15. data/app/views/chats/conversations/index.html.erb +2 -1
  16. data/app/views/chats/conversations/show.html.erb +11 -118
  17. data/app/views/chats/conversations/window.html.erb +26 -0
  18. data/app/views/chats/dock/_badge.html.erb +12 -0
  19. data/app/views/chats/dock/_dock.html.erb +130 -0
  20. data/app/views/chats/dock/_signal.html.erb +26 -0
  21. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  22. data/app/views/chats/dock/_window.html.erb +23 -0
  23. data/app/views/chats/dock/_window_header.html.erb +75 -0
  24. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  25. data/app/views/chats/messages/_composer.html.erb +11 -1
  26. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  27. data/config/importmap.rb +1 -0
  28. data/config/locales/en.yml +17 -0
  29. data/config/locales/es.yml +17 -0
  30. data/config/routes.rb +5 -0
  31. data/docs/screenshots/dock-bar.png +0 -0
  32. data/docs/screenshots/dock-desktop.png +0 -0
  33. data/docs/screenshots/dock-phone-button.png +0 -0
  34. data/docs/screenshots/dock-phone-panel.png +0 -0
  35. data/docs/screenshots/dock-phone-window.png +0 -0
  36. data/gemfiles/rails_7.1.gemfile +2 -0
  37. data/gemfiles/rails_7.2.gemfile +2 -0
  38. data/gemfiles/rails_8.1.gemfile +2 -0
  39. data/lib/chats/broadcasts.rb +76 -10
  40. data/lib/chats/inbox.rb +68 -30
  41. data/lib/chats/models/concerns/messager.rb +10 -2
  42. data/lib/chats/models/conversation.rb +19 -0
  43. data/lib/chats/version.rb +1 -1
  44. data/lib/generators/chats/templates/initializer.rb +1 -0
  45. data/lib/generators/chats/views_generator.rb +3 -2
  46. metadata +18 -2
data/lib/chats/inbox.rb CHANGED
@@ -8,9 +8,11 @@ module Chats
8
8
  # Chats::Inbox.for(alice) # rows, newest activity first
9
9
  # Chats::Inbox.for(alice, query: "madrid") # the search box
10
10
  # Chats::Inbox.for(alice, with: support_desk) # one stack's contents
11
+ # Chats::Inbox.for(alice, limit: 10) # the messaging dock's recent list
11
12
  #
12
13
  # Rows are `Chats::Conversation | Chats::InboxGroup`, sorted by last
13
- # activity descending. Grouping happens HERE and nowhere else.
14
+ # activity descending. Grouping happens HERE and nowhere else, and so does
15
+ # the rule that a conversation with no messages is never listed.
14
16
  #
15
17
  # == Why `inbox_limit` bounds ROWS, not conversations
16
18
  #
@@ -26,24 +28,30 @@ module Chats
26
28
  class Inbox
27
29
  include Enumerable
28
30
 
29
- attr_reader :viewer, :query, :with
31
+ attr_reader :viewer, :query, :with, :limit
30
32
 
31
33
  class << self
32
34
  # The inbox for +viewer+. Returns a Chats::Inbox, which enumerates its
33
35
  # rows (`to_a` for a plain Array).
34
- def for(viewer, query: nil, with: nil)
35
- new(viewer, query: query, with: with)
36
+ def for(viewer, query: nil, with: nil, limit: nil)
37
+ new(viewer, query: query, with: with, limit: limit)
36
38
  end
37
39
  end
38
40
 
39
- def initialize(viewer, query: nil, with: nil)
41
+ # +limit+ is how many ROWS come back (`config.inbox_limit` by default).
42
+ # A smaller one is a smaller query, not a slice of the big one — except
43
+ # while searching, which still looks through the whole `inbox_limit`
44
+ # window (a match is no less a match for being the eleventh most
45
+ # recent conversation) and then returns at most +limit+ hits.
46
+ def initialize(viewer, query: nil, with: nil, limit: nil)
40
47
  @viewer = viewer
41
48
  @query = query.to_s.strip.presence
42
49
  @with = with
50
+ @limit = Integer(limit || Chats.config.inbox_limit).clamp(1, Chats.config.inbox_limit)
43
51
  end
44
52
 
45
53
  # Conversation | InboxGroup rows, newest activity first, at most
46
- # `config.inbox_limit` of them.
54
+ # +limit+ of them.
47
55
  def rows
48
56
  @rows ||= build_rows
49
57
  end
@@ -111,16 +119,30 @@ module Chats
111
119
 
112
120
  private
113
121
 
114
- def limit
115
- Chats.config.inbox_limit
122
+ # How many conversations each leg of the query loads: the row limit, or
123
+ # the whole inbox window while a search filters it in Ruby.
124
+ def window
125
+ query ? Chats.config.inbox_limit : limit
116
126
  end
117
127
 
118
128
  # The viewer's own inbox relation (`Messager#chats`: `inbox_for` with
119
129
  # `config.inbox_scope` applied, and whatever the host's model adds on
120
130
  # top), memoized so it is built ONCE per inbox however many legs the
121
131
  # row query is split into (the stack aggregates build their own).
132
+ #
133
+ # Only conversations with at least one message (Conversation.
134
+ # with_messages, system messages and tombstones included): one that was
135
+ # opened and never used is not listed — not as a row, not in a
136
+ # stack, not in search, not in the `?with=` view. It still OPENS, because
137
+ # authorization reads `viewer.chats`, not this.
122
138
  def base_relation
123
- @base_relation ||= viewer.chats.includes(:last_message, :subject, participants: :messager)
139
+ @base_relation ||= listed(viewer.chats).includes(:last_message, :subject, participants: :messager)
140
+ end
141
+
142
+ # What a list may show of +relation+. The one place the rule lives, so
143
+ # the rows and the stack totals can never disagree about it.
144
+ def listed(relation)
145
+ relation.with_messages
124
146
  end
125
147
 
126
148
  # The polymorphic types worth splitting out. Empty when nothing stacks
@@ -140,15 +162,15 @@ module Chats
140
162
 
141
163
  def load_conversations
142
164
  if filtered?
143
- @ungrouped_relation = filter_to_counterpart(base_relation).limit(limit)
165
+ @ungrouped_relation = filter_to_counterpart(base_relation).limit(window)
144
166
  @stacked = []
145
167
  elsif grouped_types.empty?
146
- @ungrouped_relation = base_relation.limit(limit)
168
+ @ungrouped_relation = base_relation.limit(window)
147
169
  @stacked = []
148
170
  else
149
171
  stacked = Chats::Conversation.direct.where(id: stacked_seats)
150
- @ungrouped_relation = base_relation.where.not(id: stacked).limit(limit)
151
- recent = base_relation.direct.where(id: stacked_seats).except(:includes).select(:id).limit(limit)
172
+ @ungrouped_relation = base_relation.where.not(id: stacked).limit(window)
173
+ recent = base_relation.direct.where(id: stacked_seats).except(:includes).select(:id).limit(window)
152
174
  # Preserve the recent search window, plus the freshest conversation
153
175
  # of each counterpart. A busy stack cannot consume another stack's
154
176
  # row; at most twice the row limit is materialized.
@@ -183,7 +205,7 @@ module Chats
183
205
  messager_id: viewer.id })
184
206
  .select("chats_conversations.id, #{activity} AS activity_at", ranking)
185
207
  Chats::Conversation.from(ranked, :ranked_stacks).where("stack_rank = 1")
186
- .order(Arel.sql("activity_at DESC, id DESC")).limit(limit).select(:id)
208
+ .order(Arel.sql("activity_at DESC, id DESC")).limit(window).select(:id)
187
209
  end
188
210
 
189
211
  # Only the direct threads shared with one counterpart. Direct only, by
@@ -210,7 +232,8 @@ module Chats
210
232
  (stacks[Chats.messager_key(counterpart)] ||= [counterpart, []]).last << conversation
211
233
  end
212
234
 
213
- rows.concat(stacks.each_value.map { |messager, members| build_group(messager, members) })
235
+ totals = stack_totals(stacks.each_value.map(&:first))
236
+ rows.concat(stacks.each_value.map { |messager, members| build_group(messager, members, totals) })
214
237
  sort_rows(rows).first(limit)
215
238
  end
216
239
 
@@ -226,33 +249,48 @@ module Chats
226
249
  end
227
250
  end
228
251
 
229
- def build_group(messager, members)
230
- totals = stack_totals(messager)
252
+ def build_group(messager, members, totals)
253
+ key = stack_key(messager.class.polymorphic_name, messager.id)
231
254
 
232
255
  Chats::InboxGroup.new(
233
256
  messager: messager,
234
257
  conversations: sort_rows(members),
235
- unread_count: totals[:unread],
236
- open_count: totals[:open]
258
+ unread_count: totals[:unread].fetch(key, 0),
259
+ open_count: totals[:open].fetch(key, 0)
237
260
  )
238
261
  end
239
262
 
240
- # What a stacked row says about the WHOLE stack, in two indexed
241
- # aggregates — independent of how deep the stack is, and of how much of
242
- # it we loaded.
243
- def stack_totals(messager)
244
- scope = viewer.chats.reorder(nil).direct.where(
245
- id: Chats::Participant.select(:conversation_id).where(
246
- messager_type: messager.class.polymorphic_name, messager_id: messager.id
247
- )
248
- )
263
+ # What each stacked row says about its WHOLE stack, for every stack at
264
+ # once: two grouped aggregates, however many stacks the inbox shows and
265
+ # however deep each one runs — never per stack, never by loading one.
266
+ # Grouped by the counterpart's seat; the type and id lists may pair up
267
+ # a few seats that aren't stacks here, and those rows are simply never
268
+ # read back.
269
+ def stack_totals(messagers)
270
+ return { open: {}, unread: {} } if messagers.empty?
271
+
272
+ types = messagers.map { |messager| messager.class.polymorphic_name }.uniq
273
+ seats = listed(viewer.chats).reorder(nil).direct
274
+ .joins("INNER JOIN chats_participants stack_seats " \
275
+ "ON stack_seats.conversation_id = chats_conversations.id")
276
+ .where(stack_seats: { messager_type: types, messager_id: messagers.map(&:id) })
277
+ .group("stack_seats.messager_type", "stack_seats.messager_id")
249
278
 
250
279
  {
251
- open: scope.distinct.count,
252
- unread: scope.unread_by(viewer).reorder(nil).count("chats_messages.id")
280
+ open: keyed(seats.distinct.count("chats_conversations.id")),
281
+ unread: keyed(seats.unread_by(viewer).count("chats_messages.id"))
253
282
  }
254
283
  end
255
284
 
285
+ def keyed(counts)
286
+ counts.transform_keys { |(type, id)| stack_key(type, id) }
287
+ end
288
+
289
+ # uuid and bigint keys alike: the database may hand ids back as strings.
290
+ def stack_key(type, id)
291
+ [type, id.to_s]
292
+ end
293
+
256
294
  # The other party of a DIRECT conversation, when their class asked to be
257
295
  # stacked (`acts_as_messager inbox: :grouped`). Read from the preloaded
258
296
  # participants — no extra query per row.
@@ -184,6 +184,12 @@ module Chats
184
184
  # read it, so a conversation the scope hides is hidden everywhere. Until
185
185
  # 0.4.1 the badge counted through the raw `inbox_for` relation and could
186
186
  # light up for a conversation the inbox would never show.
187
+ #
188
+ # It is also the AUTHORIZATION relation (every engine lookup is
189
+ # `viewer.chats.find(id)`), which is why it includes conversations
190
+ # with no messages yet: the thread you just opened must open. The
191
+ # lists narrow it with `Chats::Conversation.with_messages` (see
192
+ # Chats::Inbox); `chats.with_messages` is what your own lists want too.
187
193
  def chats
188
194
  relation = Chats::Conversation.inbox_for(self)
189
195
  Chats.config.inbox_scope.call(relation, self) || relation
@@ -193,8 +199,10 @@ module Chats
193
199
  # badge counts conversations, not messages — 47 unread messages in one
194
200
  # thread is still "1 thing to deal with").
195
201
  def unread_chats_count
196
- # reorder(nil): the inbox scope orders by a COALESCE expression, which
197
- # PostgreSQL rejects inside a COUNT(DISTINCT …) aggregate.
202
+ # No `with_messages` needed to agree with the inbox: `unread_by` joins
203
+ # the messages, so a conversation with no messages can never be
204
+ # counted. reorder(nil): the inbox scope orders by a COALESCE
205
+ # expression, which PostgreSQL rejects inside a COUNT(DISTINCT …).
198
206
  chats.unread_by(self).reorder(nil).distinct.count
199
207
  end
200
208
 
@@ -59,6 +59,25 @@ module Chats
59
59
  order(Arel.sql("COALESCE(chats_conversations.last_message_at, chats_conversations.created_at) DESC"))
60
60
  }
61
61
 
62
+ # Conversations that have at least one message, system messages and
63
+ # tombstones included (`last_message_id` set). Not "a person wrote here":
64
+ # a conversation holding only a system note, or only a deleted message,
65
+ # has messages. A conversation exists from the moment it is OPENED
66
+ # (`chat_with`, the Message button), and nobody wants an inbox full of
67
+ # the threads they opened and never used — "No messages yet" rows. So
68
+ # every LIST the gem renders (the inbox, its stacks, search, the
69
+ # `?with=` view, the dock's recent list) reads through this scope, while
70
+ # the authorization relation (`Messager#chats`) does NOT: an empty
71
+ # conversation still opens when addressed directly, which is how its
72
+ # first message gets written.
73
+ #
74
+ # Free, because the denormalized pointer already says it: a conversation
75
+ # has a last message exactly when it has messages (the pointer is set on
76
+ # every create and healed on every hard delete; a soft-deleted tombstone
77
+ # is still a message, so a thread whose only message was deleted stays
78
+ # listed, like everywhere else).
79
+ scope :with_messages, -> { where.not(last_message_id: nil) }
80
+
62
81
  # A messager's inbox: every conversation they're an active participant of
63
82
  # (not left), minus direct threads with someone they're blocked with,
64
83
  # newest activity first. THE hot query — keep it composable and indexed.
data/lib/chats/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chats
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -226,6 +226,7 @@ Chats.configure do |config|
226
226
  # app/views/chats/slots/_composer_actions.html.erb your own buttons above the composer
227
227
  # app/views/chats/slots/_locked_composer.html.erb the locked composer's body
228
228
  # app/views/chats/slots/_message_meta.html.erb after each bubble's timestamp
229
+ # app/views/chats/slots/_dock_panel_footer.html.erb beside "See all messages" in the dock
229
230
  #
230
231
  # `rails generate chats:views` is still there for wholesale restyling.
231
232
  end
@@ -24,13 +24,14 @@ module Chats
24
24
  # Which groups to eject. Default copies everything renderable.
25
25
  class_option :views,
26
26
  type: :array,
27
- default: %w[conversations messages shared],
28
- desc: "Which view groups to copy (conversations, messages, shared)"
27
+ default: %w[conversations messages shared dock],
28
+ desc: "Which view groups to copy (conversations, messages, shared, dock)"
29
29
 
30
30
  def copy_views
31
31
  directory "chats/conversations", "app/views/chats/conversations" if include?("conversations")
32
32
  directory "chats/messages", "app/views/chats/messages" if include?("messages")
33
33
  directory "chats/shared", "app/views/chats/shared" if include?("shared")
34
+ directory "chats/dock", "app/views/chats/dock" if include?("dock")
34
35
  end
35
36
 
36
37
  def show_styling_tip
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-17 00:00:00.000000000 Z
10
+ date: 2026-10-18 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -135,6 +135,7 @@ files:
135
135
  - app/helpers/chats/engine_helper.rb
136
136
  - app/javascript/chats/composer_controller.js
137
137
  - app/javascript/chats/debounced_submit_controller.js
138
+ - app/javascript/chats/dock_controller.js
138
139
  - app/javascript/chats/refresh_inbox_controller.js
139
140
  - app/javascript/chats/thread_controller.js
140
141
  - app/views/chats/conversations/_conversation_row.html.erb
@@ -142,9 +143,19 @@ files:
142
143
  - app/views/chats/conversations/_locked_composer.html.erb
143
144
  - app/views/chats/conversations/_messages_page.html.erb
144
145
  - app/views/chats/conversations/_read_state.html.erb
146
+ - app/views/chats/conversations/_thread.html.erb
147
+ - app/views/chats/conversations/dock.html.erb
145
148
  - app/views/chats/conversations/index.html.erb
146
149
  - app/views/chats/conversations/refresh.turbo_stream.erb
147
150
  - app/views/chats/conversations/show.html.erb
151
+ - app/views/chats/conversations/window.html.erb
152
+ - app/views/chats/dock/_badge.html.erb
153
+ - app/views/chats/dock/_dock.html.erb
154
+ - app/views/chats/dock/_signal.html.erb
155
+ - app/views/chats/dock/_update.turbo_stream.erb
156
+ - app/views/chats/dock/_window.html.erb
157
+ - app/views/chats/dock/_window_header.html.erb
158
+ - app/views/chats/dock/_window_shell.html.erb
148
159
  - app/views/chats/messages/_action.html.erb
149
160
  - app/views/chats/messages/_composer.html.erb
150
161
  - app/views/chats/messages/_message.html.erb
@@ -162,6 +173,11 @@ files:
162
173
  - docs/PRD.md
163
174
  - docs/campfire_review.md
164
175
  - docs/screenshots/composer-actions.png
176
+ - docs/screenshots/dock-bar.png
177
+ - docs/screenshots/dock-desktop.png
178
+ - docs/screenshots/dock-phone-button.png
179
+ - docs/screenshots/dock-phone-panel.png
180
+ - docs/screenshots/dock-phone-window.png
165
181
  - docs/screenshots/inbox.png
166
182
  - docs/screenshots/system-note-action.png
167
183
  - docs/screenshots/thread-actions-phone.png