chats 0.4.2 → 0.5.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +478 -0
  3. data/README.md +150 -10
  4. data/app/assets/stylesheets/chats.css +599 -0
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +273 -55
  7. data/app/helpers/chats/engine_helper.rb +102 -6
  8. data/app/javascript/chats/composer_controller.js +14 -1
  9. data/app/javascript/chats/dock_controller.js +1106 -0
  10. data/app/javascript/chats/recovery.js +539 -0
  11. data/app/javascript/chats/refresh_inbox_controller.js +55 -65
  12. data/app/javascript/chats/thread_controller.js +317 -60
  13. data/app/javascript/chats/unread_badge_controller.js +24 -0
  14. data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
  15. data/app/views/chats/conversations/_group.html.erb +6 -2
  16. data/app/views/chats/conversations/_history.html.erb +54 -0
  17. data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
  18. data/app/views/chats/conversations/_thread.html.erb +133 -0
  19. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  20. data/app/views/chats/conversations/dock.html.erb +40 -0
  21. data/app/views/chats/conversations/index.html.erb +24 -10
  22. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  23. data/app/views/chats/conversations/show.html.erb +11 -118
  24. data/app/views/chats/conversations/window.html.erb +26 -0
  25. data/app/views/chats/dock/_badge.html.erb +12 -0
  26. data/app/views/chats/dock/_dock.html.erb +138 -0
  27. data/app/views/chats/dock/_signal.html.erb +26 -0
  28. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  29. data/app/views/chats/dock/_window.html.erb +23 -0
  30. data/app/views/chats/dock/_window_header.html.erb +75 -0
  31. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  32. data/app/views/chats/messages/_composer.html.erb +11 -1
  33. data/app/views/chats/messages/_message.html.erb +8 -6
  34. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  35. data/config/importmap.rb +7 -0
  36. data/config/locales/en.yml +17 -0
  37. data/config/locales/es.yml +17 -0
  38. data/config/routes.rb +8 -0
  39. data/docs/screenshots/dock-bar.png +0 -0
  40. data/docs/screenshots/dock-desktop.png +0 -0
  41. data/docs/screenshots/dock-phone-button.png +0 -0
  42. data/docs/screenshots/dock-phone-panel.png +0 -0
  43. data/docs/screenshots/dock-phone-window.png +0 -0
  44. data/gemfiles/rails_7.1.gemfile +2 -0
  45. data/gemfiles/rails_7.2.gemfile +2 -0
  46. data/gemfiles/rails_8.1.gemfile +2 -0
  47. data/lib/chats/broadcasts.rb +178 -17
  48. data/lib/chats/inbox.rb +68 -30
  49. data/lib/chats/inbox_refresh_job.rb +14 -0
  50. data/lib/chats/models/concerns/messager.rb +10 -2
  51. data/lib/chats/models/conversation.rb +57 -0
  52. data/lib/chats/models/message.rb +98 -9
  53. data/lib/chats/models/reaction.rb +13 -0
  54. data/lib/chats/version.rb +1 -1
  55. data/lib/generators/chats/templates/initializer.rb +1 -0
  56. data/lib/generators/chats/views_generator.rb +3 -2
  57. metadata +23 -2
@@ -81,6 +81,77 @@ module Chats
81
81
  scope :oldest_first, -> { order(created_at: :asc, id: :asc) }
82
82
  scope :visible, -> { where(deleted_at: nil) }
83
83
 
84
+ # What messagers wrote: text messages that still stand. "Human" is the
85
+ # gem's word for a text message, written by a messager, person or not
86
+ # (a bot's or a headless desk's message counts), as opposed to a system
87
+ # line ("Alice joined", "Your ride was cancelled"), which is the app
88
+ # talking. A tombstone no longer says anything. A photo with no caption
89
+ # is still a text message and still counts. The inbox does NOT read through this,
90
+ # on purpose — see README "Real conversations vs system-only threads".
91
+ scope :human, -> { where(kind: "text").visible }
92
+
93
+ # The newest human message of each given conversation, at most one row
94
+ # per conversation, in ONE query: the batch twin of
95
+ # Conversation#last_human_message for lists (admin walls, moderation
96
+ # queues). +conversations+ is a page of records, ids, a single record, or
97
+ # a Chats::Conversation relation — an unloaded relation is read as a
98
+ # subquery, never loaded first, and without its `includes`/`preload`/
99
+ # `eager_load` (they load records; a subquery has none to load). So a
100
+ # relation that FILTERS on an associated table must join it explicitly
101
+ # (`joins` / `left_joins`): `includes(:x).references(:x).where(x: …)`
102
+ # loses the join and raises. Or pass the loaded page, or its ids.
103
+ #
104
+ # The result is a relation, so the columns can be narrowed:
105
+ #
106
+ # Chats::Message.last_human_in(conversations)
107
+ # .select(:id, :conversation_id, :created_at)
108
+ # .index_by(&:conversation_id) # { id => message }
109
+ #
110
+ # A conversation nobody wrote in has no row. ROW_NUMBER works on every
111
+ # supported adapter (PostgreSQL, SQLite and MySQL 8), the same ranking
112
+ # Chats::Inbox uses for its stacks. The ranked subquery is built unscoped,
113
+ # so neither a scope it is called on nor a `.select` chained onto the
114
+ # result leaks into it.
115
+ scope :last_human_in, lambda { |conversations|
116
+ ids = Chats::Message.conversation_ids_in(conversations)
117
+ next none if ids.is_a?(Array) && ids.empty?
118
+
119
+ ranking = "ROW_NUMBER() OVER (PARTITION BY chats_messages.conversation_id " \
120
+ "ORDER BY chats_messages.created_at DESC, chats_messages.id DESC) AS human_rank"
121
+ ranked = Chats::Message.unscoped.human.where(conversation_id: ids)
122
+ .select(Arel.sql("chats_messages.id"), Arel.sql(ranking))
123
+ newest = Chats::Message.unscoped.from(ranked, :ranked_messages)
124
+ .where(Arel.sql("ranked_messages.human_rank = 1"))
125
+ .select(Arel.sql("ranked_messages.id"))
126
+ where(id: newest)
127
+ }
128
+
129
+ # The conversation ids +last_human_in+ ranks within: an Array for records
130
+ # and ids (a loaded relation included, read from memory), a subquery for
131
+ # an unloaded relation, as a derived table because MySQL rejects LIMIT
132
+ # directly inside IN, and a page is usually limited.
133
+ #
134
+ # A LIMITed or OFFSET page is its own query, word for word (its select
135
+ # list, DISTINCT and ORDER BY decide WHICH rows it holds, and PostgreSQL
136
+ # refuses a DISTINCT whose ORDER BY left the select list), so it must
137
+ # expose an `id` column, as the default select list does. Any other
138
+ # relation is just a set of ids: narrowed to `id`, unordered (the inbox's
139
+ # ORDER BY COALESCE(…) next to DISTINCT id is another refusal).
140
+ def self.conversation_ids_in(conversations) # :nodoc:
141
+ unless conversations.is_a?(ActiveRecord::Relation)
142
+ return Array(conversations).map { |c| c.is_a?(Chats::Conversation) ? c.id : c }
143
+ end
144
+ unless conversations.klass <= Chats::Conversation
145
+ raise ArgumentError, "expected Chats::Conversation records, ids or a relation of them, " \
146
+ "got a #{conversations.klass.name} relation"
147
+ end
148
+ return conversations.map(&:id) if conversations.loaded?
149
+
150
+ page = conversations.except(:includes, :preload, :eager_load)
151
+ page = page.reselect(page.arel_table[:id]).reorder(nil) unless page.limit_value || page.offset_value
152
+ Chats::Conversation.unscoped.from(page, :conversation_page).select(Arel.sql("conversation_page.id"))
153
+ end
154
+
84
155
  # Keyset (cursor) pagination for infinite scroll-up. OFFSET pagination
85
156
  # drifts when new messages arrive mid-scroll; anchoring on the oldest
86
157
  # loaded message can't skip or duplicate. `(created_at, id)` because
@@ -93,17 +164,35 @@ module Chats
93
164
  }
94
165
 
95
166
  # Catch-up scopes for ConversationsController#refresh (the stale-thread
96
- # recovery path — see the thread controller's refresh trigger). `since`
97
- # is the newest `updated_at` the client has rendered:
98
- # created_since — messages that arrived while the tab was asleep
99
- # (appended);
100
- # updated_since — messages the client HAS rendered that changed since
101
- # (edits, soft-delete tombstones — replaced in place).
102
- # `updated_since` excludes fresh rows so nothing renders twice — a Turbo
103
- # append of an existing dom_id would otherwise also MOVE that bubble to
104
- # the end (Turbo removes-then-appends on id collision).
167
+ # recovery path — see the thread controller's catch-up), on the client's
168
+ # two cursors. Each is a moment, to the microsecond, up to which the
169
+ # client KNOWS it has everything (a complete render, or a catch-up that
170
+ # succeeded — never a live bubble, which can arrive past one it missed),
171
+ # with the ids it has on screen from that moment on:
172
+ # created_after(at, seen:) — created at or after that moment and not
173
+ # on screen: appended;
174
+ # created_through(at, seen:) — the complement: what the client has;
175
+ # updated_after(at, seen:) — changed after that moment, or at it and
176
+ # not among the ones seen in that version.
177
+ # Replacements are created_through(after).updated_after(changed): changes
178
+ # to what is on screen, never a second copy of what is appended — a Turbo
179
+ # append of an existing dom_id would MOVE that bubble to the end. Two
180
+ # cursors, because one can't answer both: a message created at 10:15 and
181
+ # missed is older than a 10:20 edit that did arrive.
105
182
  # Pattern from Basecamp's Campfire (Rooms::RefreshesController):
106
183
  # https://github.com/basecamp/once-campfire
184
+ scope :created_after, lambda { |at, seen: []|
185
+ where(arel_table[:created_at].gteq(at)).where.not(id: seen)
186
+ }
187
+ scope :created_through, lambda { |at, seen: []|
188
+ where(arel_table[:created_at].lt(at)).or(where(id: seen))
189
+ }
190
+ scope :updated_after, lambda { |at, seen: []|
191
+ where(arel_table[:updated_at].gt(at)).or(where(updated_at: at).where.not(id: seen))
192
+ }
193
+ # The one-cursor catch-up of bubbles ejected before they carried the
194
+ # cursors above (`data-updated-at-ms` alone, rounded up): what was
195
+ # created after it, and what was rendered by then and changed since.
107
196
  scope :created_since, ->(time) { where("chats_messages.created_at > ?", time) }
108
197
  scope :updated_since, lambda { |time|
109
198
  where("chats_messages.updated_at > ?", time).where("chats_messages.created_at <= ?", time)
@@ -27,6 +27,15 @@ module Chats
27
27
  # Rails behavior: https://guides.rubyonrails.org/active_record_callbacks.html#using-both-after-create-commit-and-after-update-commit
28
28
  after_commit :broadcast_change, on: %i[create destroy]
29
29
 
30
+ # A reaction changes how its message renders, so it moves the message's
31
+ # updated_at: that is what a thread catching up on missed broadcasts asks
32
+ # for (Message.updated_after). update_columns, not touch: the
33
+ # broadcast above already re-renders the bubble, and touch would run the
34
+ # message's own update callbacks and broadcast it a second time. Not when
35
+ # the message itself is being destroyed and takes its reactions with it.
36
+ after_create :stamp_message
37
+ after_destroy :stamp_message, unless: :destroyed_by_association
38
+
30
39
  # Add the reaction if absent, remove it if present (the universal
31
40
  # tap-to-toggle semantic). Returns the created reaction, or false when
32
41
  # toggled off. Race-safe: a concurrent double-tap resolves through the
@@ -68,6 +77,10 @@ module Chats
68
77
  errors.add(:reactor, :not_a_participant) unless message.conversation.participant?(reactor)
69
78
  end
70
79
 
80
+ def stamp_message
81
+ message.update_columns(updated_at: Time.current)
82
+ end
83
+
71
84
  def broadcast_change
72
85
  Chats::Broadcasts.message_updated(message) unless message.destroyed?
73
86
  end
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.1"
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.1
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,16 +135,31 @@ 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
139
+ - app/javascript/chats/recovery.js
138
140
  - app/javascript/chats/refresh_inbox_controller.js
139
141
  - app/javascript/chats/thread_controller.js
142
+ - app/javascript/chats/unread_badge_controller.js
140
143
  - app/views/chats/conversations/_conversation_row.html.erb
141
144
  - app/views/chats/conversations/_group.html.erb
145
+ - app/views/chats/conversations/_history.html.erb
142
146
  - app/views/chats/conversations/_locked_composer.html.erb
143
147
  - app/views/chats/conversations/_messages_page.html.erb
144
148
  - app/views/chats/conversations/_read_state.html.erb
149
+ - app/views/chats/conversations/_thread.html.erb
150
+ - app/views/chats/conversations/badge.turbo_stream.erb
151
+ - app/views/chats/conversations/dock.html.erb
145
152
  - app/views/chats/conversations/index.html.erb
146
153
  - app/views/chats/conversations/refresh.turbo_stream.erb
147
154
  - app/views/chats/conversations/show.html.erb
155
+ - app/views/chats/conversations/window.html.erb
156
+ - app/views/chats/dock/_badge.html.erb
157
+ - app/views/chats/dock/_dock.html.erb
158
+ - app/views/chats/dock/_signal.html.erb
159
+ - app/views/chats/dock/_update.turbo_stream.erb
160
+ - app/views/chats/dock/_window.html.erb
161
+ - app/views/chats/dock/_window_header.html.erb
162
+ - app/views/chats/dock/_window_shell.html.erb
148
163
  - app/views/chats/messages/_action.html.erb
149
164
  - app/views/chats/messages/_composer.html.erb
150
165
  - app/views/chats/messages/_message.html.erb
@@ -162,6 +177,11 @@ files:
162
177
  - docs/PRD.md
163
178
  - docs/campfire_review.md
164
179
  - docs/screenshots/composer-actions.png
180
+ - docs/screenshots/dock-bar.png
181
+ - docs/screenshots/dock-desktop.png
182
+ - docs/screenshots/dock-phone-button.png
183
+ - docs/screenshots/dock-phone-panel.png
184
+ - docs/screenshots/dock-phone-window.png
165
185
  - docs/screenshots/inbox.png
166
186
  - docs/screenshots/system-note-action.png
167
187
  - docs/screenshots/thread-actions-phone.png
@@ -176,6 +196,7 @@ files:
176
196
  - lib/chats/errors.rb
177
197
  - lib/chats/inbox.rb
178
198
  - lib/chats/inbox_group.rb
199
+ - lib/chats/inbox_refresh_job.rb
179
200
  - lib/chats/macros.rb
180
201
  - lib/chats/models/application_record.rb
181
202
  - lib/chats/models/concerns/chat_subject.rb