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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +478 -0
- data/README.md +150 -10
- data/app/assets/stylesheets/chats.css +599 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +273 -55
- data/app/helpers/chats/engine_helper.rb +102 -6
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1106 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/thread_controller.js +317 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
- data/app/views/chats/conversations/_group.html.erb +6 -2
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +133 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +24 -10
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +11 -118
- data/app/views/chats/conversations/window.html.erb +26 -0
- data/app/views/chats/dock/_badge.html.erb +12 -0
- data/app/views/chats/dock/_dock.html.erb +138 -0
- data/app/views/chats/dock/_signal.html.erb +26 -0
- data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
- data/app/views/chats/dock/_window.html.erb +23 -0
- data/app/views/chats/dock/_window_header.html.erb +75 -0
- data/app/views/chats/dock/_window_shell.html.erb +17 -0
- data/app/views/chats/messages/_composer.html.erb +11 -1
- data/app/views/chats/messages/_message.html.erb +8 -6
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +17 -0
- data/config/locales/es.yml +17 -0
- data/config/routes.rb +8 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/gemfiles/rails_7.1.gemfile +2 -0
- data/gemfiles/rails_7.2.gemfile +2 -0
- data/gemfiles/rails_8.1.gemfile +2 -0
- data/lib/chats/broadcasts.rb +178 -17
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +57 -0
- data/lib/chats/models/message.rb +98 -9
- data/lib/chats/models/reaction.rb +13 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +23 -2
data/lib/chats/models/message.rb
CHANGED
|
@@ -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
|
|
97
|
-
# is
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
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
|
@@ -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
|
+
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-
|
|
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
|