chats 0.1.1 → 0.6.2
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 +1037 -0
- data/README.md +621 -40
- data/app/assets/stylesheets/chats.css +906 -2
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +396 -86
- data/app/controllers/chats/messages_controller.rb +35 -13
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +230 -7
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1120 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +76 -0
- data/app/javascript/chats/split_controller.js +131 -0
- data/app/javascript/chats/thread_controller.js +322 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +61 -0
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
- data/app/views/chats/conversations/_thread.html.erb +147 -0
- data/app/views/chats/conversations/_thread_header.html.erb +83 -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 +142 -15
- data/app/views/chats/conversations/pane.html.erb +14 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +18 -134
- 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 +139 -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/_action.html.erb +15 -0
- data/app/views/chats/messages/_composer.html.erb +84 -56
- data/app/views/chats/messages/_message.html.erb +43 -15
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +10 -1
- data/config/locales/en.yml +40 -0
- data/config/locales/es.yml +40 -0
- data/config/routes.rb +8 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/docs/screenshots/composer-actions.png +0 -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/docs/screenshots/inbox.png +0 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/docs/screenshots/system-note-action.png +0 -0
- data/docs/screenshots/thread-actions-phone.png +0 -0
- data/gemfiles/rails_7.1.gemfile +3 -0
- data/gemfiles/rails_7.2.gemfile +3 -0
- data/gemfiles/rails_8.1.gemfile +3 -0
- data/lib/chats/action.rb +85 -0
- data/lib/chats/broadcasts.rb +178 -17
- data/lib/chats/configuration.rb +115 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +363 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/inbox_refresh_job.rb +14 -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 +122 -6
- data/lib/chats/models/conversation.rb +153 -19
- data/lib/chats/models/message.rb +292 -21
- data/lib/chats/models/participant.rb +67 -0
- data/lib/chats/models/reaction.rb +18 -0
- data/lib/chats/send_rate_limited.rb +30 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +123 -15
- data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
- data/lib/generators/chats/templates/initializer.rb +125 -14
- data/lib/generators/chats/upgrade_generator.rb +79 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +51 -2
data/lib/chats/engine.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Chats
|
|
|
30
30
|
CHATS_LIB = File.expand_path("chats", LIB_ROOT)
|
|
31
31
|
|
|
32
32
|
ZEITWERK_IGNORED = %w[
|
|
33
|
-
version.rb errors.rb configuration.rb engine.rb macros.rb
|
|
33
|
+
version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb send_rate_limited.rb
|
|
34
34
|
].freeze
|
|
35
35
|
|
|
36
36
|
initializer "chats.autoload", before: :set_autoload_paths do
|
|
@@ -64,6 +64,13 @@ module Chats
|
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# Hand the gem's deprecator to the app, so `config.active_support.
|
|
68
|
+
# deprecation` (and `deprecators.silence`) govern chats' own deprecation
|
|
69
|
+
# warnings like any other framework's.
|
|
70
|
+
initializer "chats.deprecator" do |app|
|
|
71
|
+
app.deprecators[:chats] = Chats.deprecator if app.respond_to?(:deprecators)
|
|
72
|
+
end
|
|
73
|
+
|
|
67
74
|
# Expose `acts_as_messager` / `acts_as_chat_subject` on every AR model.
|
|
68
75
|
initializer "chats.active_record" do
|
|
69
76
|
ActiveSupport.on_load(:active_record) do
|
|
@@ -71,11 +78,20 @@ module Chats
|
|
|
71
78
|
end
|
|
72
79
|
end
|
|
73
80
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
# The gem's locale files (en, es) ship through Rails::Engine's own
|
|
82
|
+
# :add_locales initializer, which picks up every engine's config/locales
|
|
83
|
+
# automatically — and deliberately NOT through a manual
|
|
84
|
+
# `app.config.i18n.load_path +=` on top of it.
|
|
85
|
+
#
|
|
86
|
+
# That append is not merely redundant, it inverts the contract: railtie
|
|
87
|
+
# paths are unshifted ahead of everything in load_path, so an appended
|
|
88
|
+
# copy lands AFTER the host's own locales and silently overrides them. A
|
|
89
|
+
# host rewording `chats.flashes.blocked` in its own es.yml would keep
|
|
90
|
+
# reading ours, with no error and nothing to see.
|
|
91
|
+
#
|
|
92
|
+
# Gem first, host last. `clickwrap` carries the same note; `support_desk`
|
|
93
|
+
# shipped the bug and measured it (its file sat in load_path 14 times and
|
|
94
|
+
# the host's override lost).
|
|
79
95
|
|
|
80
96
|
# NOTE: the host-facing helpers (`chat_button_to`, `chats_unread_badge`, …)
|
|
81
97
|
# are exposed to ActionView from the BOTTOM of engine_helper.rb itself
|
|
@@ -90,7 +106,8 @@ module Chats
|
|
|
90
106
|
|
|
91
107
|
# -------------------------------------------------------------------------
|
|
92
108
|
# JavaScript: the engine ships tiny Stimulus controllers (thread, composer,
|
|
93
|
-
# debounced-submit) with NO build step, pinned for
|
|
109
|
+
# debounced-submit, refresh-inbox) with NO build step, pinned for
|
|
110
|
+
# importmap-rails hosts.
|
|
94
111
|
#
|
|
95
112
|
# The pin keys live under "controllers/chats/..." ON PURPOSE: the stock
|
|
96
113
|
# Rails `app/javascript/controllers/index.js` calls
|
|
@@ -125,6 +142,11 @@ module Chats
|
|
|
125
142
|
if app.config.respond_to?(:assets)
|
|
126
143
|
app.config.assets.paths << root.join("app/javascript")
|
|
127
144
|
app.config.assets.paths << root.join("app/assets/stylesheets")
|
|
145
|
+
|
|
146
|
+
# Propshaft serves anything on the load path; Sprockets serves only
|
|
147
|
+
# what is on the precompile list, so a Sprockets host 404s the
|
|
148
|
+
# stylesheet without this line.
|
|
149
|
+
app.config.assets.precompile << "chats.css" if app.config.assets.respond_to?(:precompile)
|
|
128
150
|
end
|
|
129
151
|
end
|
|
130
152
|
|
data/lib/chats/errors.rb
CHANGED
|
@@ -17,4 +17,20 @@ module Chats
|
|
|
17
17
|
# Raised when the host `can_message` policy (or a feature flag like
|
|
18
18
|
# `config.groups = false`) forbids the attempted action.
|
|
19
19
|
class NotAllowedError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised when a conversation's SUBJECT has locked it (see
|
|
22
|
+
# Chats::ChatSubject#chat_locked?) and something tries to write to it
|
|
23
|
+
# anyway — send, edit, delete, react. A subclass of NotAllowedError on
|
|
24
|
+
# purpose: a host that already rescues NotAllowedError keeps working, and
|
|
25
|
+
# one that wants to show the lock notice specifically can rescue this.
|
|
26
|
+
# System messages are never refused: the app must always be able to say
|
|
27
|
+
# "this was closed" in the thread it just closed.
|
|
28
|
+
class LockedError < NotAllowedError
|
|
29
|
+
attr_reader :conversation
|
|
30
|
+
|
|
31
|
+
def initialize(message = nil, conversation: nil)
|
|
32
|
+
@conversation = conversation
|
|
33
|
+
super(message || conversation&.locked_notice || "this conversation is closed")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
20
36
|
end
|
data/lib/chats/inbox.rb
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# THE inbox query, in one object: load a viewer's recent conversations,
|
|
5
|
+
# optionally filter them, and fold the ones that belong to a stacked
|
|
6
|
+
# counterpart into Chats::InboxGroup rows.
|
|
7
|
+
#
|
|
8
|
+
# Chats::Inbox.for(alice) # rows, newest activity first
|
|
9
|
+
# Chats::Inbox.for(alice, query: "madrid") # the search box
|
|
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
|
|
12
|
+
#
|
|
13
|
+
# Rows are `Chats::Conversation | Chats::InboxGroup`, sorted by last
|
|
14
|
+
# activity descending. Grouping happens HERE and nowhere else, and so does
|
|
15
|
+
# the rule that a conversation with no messages is never listed.
|
|
16
|
+
#
|
|
17
|
+
# == Why `inbox_limit` bounds ROWS, not conversations
|
|
18
|
+
#
|
|
19
|
+
# A stacked counterpart can hold hundreds of threads. Limiting the raw
|
|
20
|
+
# conversation query first would let a busy support desk EVICT everything
|
|
21
|
+
# else from the inbox — 200 desk threads and not one message from a friend.
|
|
22
|
+
# So the two populations are queried separately: ordinary conversations get
|
|
23
|
+
# the limit, stacked ones get their own bounded window, and the limit is
|
|
24
|
+
# applied again to the ROWS that come out. A stack's numbers
|
|
25
|
+
# (`open_count`, `unread_count`) are then GLOBAL, read with two indexed
|
|
26
|
+
# aggregates per stack — never per conversation, and never by loading the
|
|
27
|
+
# stack to count it.
|
|
28
|
+
class Inbox
|
|
29
|
+
include Enumerable
|
|
30
|
+
|
|
31
|
+
attr_reader :viewer, :query, :with, :limit
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# The inbox for +viewer+. Returns a Chats::Inbox, which enumerates its
|
|
35
|
+
# rows (`to_a` for a plain Array).
|
|
36
|
+
def for(viewer, query: nil, with: nil, limit: nil)
|
|
37
|
+
new(viewer, query: query, with: with, limit: limit)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
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)
|
|
47
|
+
@viewer = viewer
|
|
48
|
+
@query = query.to_s.strip.presence
|
|
49
|
+
@with = with
|
|
50
|
+
@limit = Integer(limit || Chats.config.inbox_limit).clamp(1, Chats.config.inbox_limit)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Conversation | InboxGroup rows, newest activity first, at most
|
|
54
|
+
# +limit+ of them.
|
|
55
|
+
def rows
|
|
56
|
+
@rows ||= build_rows
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Yield each row (Enumerable gives `map`, `select`, `find`, … from here).
|
|
60
|
+
def each(&)
|
|
61
|
+
rows.each(&)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The rows as a plain Array.
|
|
65
|
+
def to_a
|
|
66
|
+
rows
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# How many rows the inbox has.
|
|
70
|
+
def size
|
|
71
|
+
rows.size
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Whether the inbox has any rows at all.
|
|
75
|
+
def any?
|
|
76
|
+
rows.any?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Whether the inbox has no rows.
|
|
80
|
+
def empty?
|
|
81
|
+
rows.empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The conversations behind the rows, stacked threads included. Stays an
|
|
85
|
+
# ActiveRecord::Relation (already loaded) in the simple case — no search,
|
|
86
|
+
# nothing stacked — so an inbox ejected under 0.1.x can still chain
|
|
87
|
+
# `.where` or hand it to a paginator. Becomes an Array once rows had to
|
|
88
|
+
# be assembled in Ruby.
|
|
89
|
+
def conversations
|
|
90
|
+
rows
|
|
91
|
+
@flat
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# { conversation_id => unread message count } — one grouped query for
|
|
95
|
+
# every loaded conversation, which is what the row badges read.
|
|
96
|
+
def unread_counts
|
|
97
|
+
@unread_counts ||= Chats::Conversation.unread_counts_for(viewer, conversations)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Unread messages in one conversation, from the grouped query above.
|
|
101
|
+
def unread_count_for(conversation)
|
|
102
|
+
unread_counts.fetch(conversation.id, 0)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The stack-aware badge number: how many inbox ROWS carry unread content
|
|
106
|
+
# (a stack of five unread threads is still one thing to deal with).
|
|
107
|
+
# `Messager#unread_chats_count` is the unstacked count and is unchanged.
|
|
108
|
+
def unread_count
|
|
109
|
+
rows.count do |row|
|
|
110
|
+
row.is_a?(Chats::InboxGroup) ? row.unread? : unread_count_for(row).positive?
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# True when scoped to one counterpart (`?with=`): a stack's contents,
|
|
115
|
+
# which are listed individually rather than re-stacked.
|
|
116
|
+
def filtered?
|
|
117
|
+
with.present?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
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
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The viewer's own inbox relation (`Messager#chats`: `inbox_for` with
|
|
129
|
+
# `config.inbox_scope` applied, and whatever the host's model adds on
|
|
130
|
+
# top), memoized so it is built ONCE per inbox however many legs the
|
|
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.
|
|
138
|
+
def base_relation
|
|
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
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The polymorphic types worth splitting out. Empty when nothing stacks
|
|
149
|
+
# (an ordinary app pays nothing) and when the inbox is already filtered
|
|
150
|
+
# to one counterpart.
|
|
151
|
+
def grouped_types
|
|
152
|
+
@grouped_types ||= filtered? ? [] : Chats.grouped_messager_types
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Seats held by a stacked messager — never the viewer's own seat, so a
|
|
156
|
+
# stacked messager's OWN inbox stays flat.
|
|
157
|
+
def stacked_seats
|
|
158
|
+
Chats::Participant.select(:conversation_id)
|
|
159
|
+
.where(messager_type: grouped_types)
|
|
160
|
+
.where.not(messager_type: viewer.class.polymorphic_name, messager_id: viewer.id)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def load_conversations
|
|
164
|
+
if filtered?
|
|
165
|
+
@ungrouped_relation = filter_to_counterpart(base_relation).limit(window)
|
|
166
|
+
@stacked = []
|
|
167
|
+
elsif grouped_types.empty?
|
|
168
|
+
@ungrouped_relation = base_relation.limit(window)
|
|
169
|
+
@stacked = []
|
|
170
|
+
else
|
|
171
|
+
stacked = Chats::Conversation.direct.where(id: stacked_seats)
|
|
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)
|
|
174
|
+
# Preserve the recent search window, plus the freshest conversation
|
|
175
|
+
# of each counterpart. A busy stack cannot consume another stack's
|
|
176
|
+
# row; at most twice the row limit is materialized.
|
|
177
|
+
candidates = base_relation.where(id: limited_ids(recent))
|
|
178
|
+
.or(base_relation.where(id: limited_ids(stack_representatives)))
|
|
179
|
+
@stacked = apply_search(candidates)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
@ungrouped = apply_search(@ungrouped_relation)
|
|
183
|
+
# The relation itself when nothing had to be assembled in Ruby (it is
|
|
184
|
+
# loaded, so iterating it costs nothing extra); the flat Array otherwise.
|
|
185
|
+
@flat = @stacked.empty? && query.nil? ? @ungrouped_relation : @ungrouped + @stacked
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# MySQL rejects LIMIT directly in an IN subquery. A derived table keeps
|
|
189
|
+
# the bounded selection in SQL and works on all three supported adapters.
|
|
190
|
+
def limited_ids(relation)
|
|
191
|
+
Chats::Conversation.from(relation, :limited_conversations).select(:id)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# ROW_NUMBER works on every supported adapter (PostgreSQL, SQLite and
|
|
195
|
+
# MySQL 8). Ranking in SQL avoids loading an entire busy desk's history.
|
|
196
|
+
def stack_representatives
|
|
197
|
+
activity = "COALESCE(chats_conversations.last_message_at, chats_conversations.created_at)"
|
|
198
|
+
ranking = "ROW_NUMBER() OVER (PARTITION BY stack_seats.messager_type, stack_seats.messager_id " \
|
|
199
|
+
"ORDER BY #{activity} DESC, chats_conversations.id DESC) AS stack_rank"
|
|
200
|
+
ranked = base_relation.direct.except(:includes, :order)
|
|
201
|
+
.joins("INNER JOIN chats_participants stack_seats " \
|
|
202
|
+
"ON stack_seats.conversation_id = chats_conversations.id")
|
|
203
|
+
.where(stack_seats: { messager_type: grouped_types })
|
|
204
|
+
.where.not(stack_seats: { messager_type: viewer.class.polymorphic_name,
|
|
205
|
+
messager_id: viewer.id })
|
|
206
|
+
.select("chats_conversations.id, #{activity} AS activity_at", ranking)
|
|
207
|
+
Chats::Conversation.from(ranked, :ranked_stacks).where("stack_rank = 1")
|
|
208
|
+
.order(Arel.sql("activity_at DESC, id DESC")).limit(window).select(:id)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Only the direct threads shared with one counterpart. Direct only, by
|
|
212
|
+
# design: a group that happens to include the desk is not part of the
|
|
213
|
+
# desk's stack.
|
|
214
|
+
def filter_to_counterpart(relation)
|
|
215
|
+
seats = Chats::Participant.select(:conversation_id).where(
|
|
216
|
+
messager_type: with.class.polymorphic_name, messager_id: with.id
|
|
217
|
+
)
|
|
218
|
+
relation.direct.where(id: seats)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def build_rows
|
|
222
|
+
load_conversations
|
|
223
|
+
rows = @ungrouped.dup
|
|
224
|
+
stacks = {}
|
|
225
|
+
|
|
226
|
+
@stacked.each do |conversation|
|
|
227
|
+
counterpart = stacked_counterpart(conversation)
|
|
228
|
+
# The SQL prefilter matches by polymorphic type; an STI sibling that
|
|
229
|
+
# isn't actually grouped lands here and goes back to being a row.
|
|
230
|
+
next rows << conversation if counterpart.nil?
|
|
231
|
+
|
|
232
|
+
(stacks[Chats.messager_key(counterpart)] ||= [counterpart, []]).last << conversation
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
totals = stack_totals(stacks.each_value.map(&:first))
|
|
236
|
+
rows.concat(stacks.each_value.map { |messager, members| build_group(messager, members, totals) })
|
|
237
|
+
sort_rows(rows).first(limit)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Newest activity first, exactly like the SQL the relation would have
|
|
241
|
+
# used (COALESCE(last_message_at, created_at) DESC), at full timestamp
|
|
242
|
+
# precision — `to_r`, not `to_f`, because two messages a microsecond
|
|
243
|
+
# apart must not collapse into a tie. Ties break on id, so the order is
|
|
244
|
+
# total and a row never shuffles between renders.
|
|
245
|
+
def sort_rows(rows)
|
|
246
|
+
rows.sort do |a, b|
|
|
247
|
+
by_activity = sort_key(b).to_r <=> sort_key(a).to_r
|
|
248
|
+
by_activity.zero? ? compare_ids(b, a) : by_activity
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def build_group(messager, members, totals)
|
|
253
|
+
key = stack_key(messager.class.polymorphic_name, messager.id)
|
|
254
|
+
|
|
255
|
+
Chats::InboxGroup.new(
|
|
256
|
+
messager: messager,
|
|
257
|
+
conversations: sort_rows(members),
|
|
258
|
+
unread_count: totals[:unread].fetch(key, 0),
|
|
259
|
+
open_count: totals[:open].fetch(key, 0)
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
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")
|
|
278
|
+
|
|
279
|
+
{
|
|
280
|
+
open: keyed(seats.distinct.count("chats_conversations.id")),
|
|
281
|
+
unread: keyed(seats.unread_by(viewer).count("chats_messages.id"))
|
|
282
|
+
}
|
|
283
|
+
end
|
|
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
|
+
|
|
294
|
+
# The other party of a DIRECT conversation, when their class asked to be
|
|
295
|
+
# stacked (`acts_as_messager inbox: :grouped`). Read from the preloaded
|
|
296
|
+
# participants — no extra query per row.
|
|
297
|
+
def stacked_counterpart(conversation)
|
|
298
|
+
return nil unless conversation.direct?
|
|
299
|
+
|
|
300
|
+
other = conversation.participants.find do |participant|
|
|
301
|
+
participant.messager.present? && participant.messager != viewer
|
|
302
|
+
end&.messager
|
|
303
|
+
|
|
304
|
+
other if Chats.grouped_inbox?(other)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def sort_key(row)
|
|
308
|
+
row.last_message_at || (row.respond_to?(:created_at) ? row.created_at : nil) || Chats::Conversation::EPOCH
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# The total-order tiebreak: a conversation's own id, a stack's freshest
|
|
312
|
+
# conversation's id. Works for bigint and uuid keys alike, and never
|
|
313
|
+
# raises on a pair it can't compare — it just calls them equal.
|
|
314
|
+
def compare_ids(a, b)
|
|
315
|
+
left = tiebreak(a)
|
|
316
|
+
right = tiebreak(b)
|
|
317
|
+
return 0 if left.nil? || right.nil? || left.class != right.class
|
|
318
|
+
|
|
319
|
+
left <=> right
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def tiebreak(row)
|
|
323
|
+
row.is_a?(Chats::InboxGroup) ? row.conversation&.id : row.id
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Partial, case-insensitive matching across the inbox metadata users can
|
|
327
|
+
# actually see: participant names, conversation titles, subject labels,
|
|
328
|
+
# and message bodies. Each leg is capped (config.inbox_limit), so
|
|
329
|
+
# metadata is filtered portably in Ruby from the already-preloaded
|
|
330
|
+
# objects while the potentially larger message-body set stays in SQL. No
|
|
331
|
+
# PostgreSQL-only full-text dependency is needed at this scale.
|
|
332
|
+
def apply_search(relation)
|
|
333
|
+
return relation.to_a unless Chats.config.search && query
|
|
334
|
+
|
|
335
|
+
loaded = relation.to_a
|
|
336
|
+
normalized_query = query.downcase
|
|
337
|
+
message_match_ids = conversations_matching_body(loaded)
|
|
338
|
+
|
|
339
|
+
loaded.select do |conversation|
|
|
340
|
+
message_match_ids.include?(conversation.id) ||
|
|
341
|
+
searchable_metadata(conversation).downcase.include?(normalized_query)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def conversations_matching_body(conversations)
|
|
346
|
+
return [] if Chats.config.encrypt_messages || conversations.empty?
|
|
347
|
+
|
|
348
|
+
pattern = "%#{Chats::Conversation.sanitize_sql_like(query.downcase)}%"
|
|
349
|
+
Chats::Message.where(conversation_id: conversations.map(&:id), deleted_at: nil)
|
|
350
|
+
.where("LOWER(chats_messages.body) LIKE ?", pattern)
|
|
351
|
+
.distinct
|
|
352
|
+
.pluck(:conversation_id)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def searchable_metadata(conversation)
|
|
356
|
+
participant_names = conversation.participants.filter_map do |participant|
|
|
357
|
+
Chats.display_name_for(participant.messager) if participant.active?
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
[conversation.title, conversation.subject_label, *participant_names].compact.join(" ")
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# A stack of direct conversations that share one counterpart, shown as a
|
|
5
|
+
# SINGLE inbox row. Built by Chats::Inbox for messagers declared with
|
|
6
|
+
# `acts_as_messager inbox: :grouped` — a support desk, a marketplace
|
|
7
|
+
# storefront, any seat a person ends up with many separate threads with.
|
|
8
|
+
#
|
|
9
|
+
# It quacks like the parts of Chats::Conversation the inbox row needs
|
|
10
|
+
# (+last_message+, +last_message_at+, +unread_count+) so the two row
|
|
11
|
+
# partials stay symmetrical.
|
|
12
|
+
#
|
|
13
|
+
# +unread_count+ and +open_count+ are GLOBAL: they describe the whole
|
|
14
|
+
# stack, however deep it runs, not the bounded window in +conversations+.
|
|
15
|
+
# Chats::Inbox reads them with two indexed aggregates per stack.
|
|
16
|
+
class InboxGroup
|
|
17
|
+
# +conversations+ is the loaded WINDOW of the stack (freshest first,
|
|
18
|
+
# bounded by config.inbox_limit); +open_count+ and +unread_count+ describe
|
|
19
|
+
# the whole stack, however deep it runs.
|
|
20
|
+
attr_reader :messager, :conversations, :unread_count, :open_count
|
|
21
|
+
|
|
22
|
+
def initialize(messager:, conversations:, unread_count: 0, open_count: nil)
|
|
23
|
+
@messager = messager
|
|
24
|
+
@conversations = conversations
|
|
25
|
+
@unread_count = unread_count
|
|
26
|
+
@open_count = open_count || conversations.size
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A stack of one is really just a conversation: the row links straight to
|
|
30
|
+
# it, and the thread carries a "see all" link back to the stack.
|
|
31
|
+
def single?
|
|
32
|
+
open_count == 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The freshest conversation in the stack — what the row previews, and
|
|
36
|
+
# what it links to when the stack holds exactly one.
|
|
37
|
+
def conversation
|
|
38
|
+
conversations.first
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The stack's most recent message (the row's preview line).
|
|
42
|
+
def last_message
|
|
43
|
+
conversation&.last_message
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The sort key the inbox orders rows by: the freshest activity in the
|
|
47
|
+
# stack (mirrors Conversation.recent_first's COALESCE).
|
|
48
|
+
def last_message_at
|
|
49
|
+
conversations.filter_map { |c| c.last_message_at || c.created_at }.max
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether the stack has anything unread in it, anywhere.
|
|
53
|
+
def unread?
|
|
54
|
+
unread_count.positive?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What the row is called. A stack is named after its counterpart from
|
|
58
|
+
# every seat, so the viewer is accepted and ignored — the signature
|
|
59
|
+
# matches Chats::Conversation#title_for so both row partials can call it.
|
|
60
|
+
def title_for(_viewer)
|
|
61
|
+
Chats.display_name_for(messager)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Stable DOM id for the row (no AR record to derive one from).
|
|
65
|
+
def dom_id
|
|
66
|
+
"chats_inbox_group_#{messager.class.polymorphic_name.underscore.tr("/", "_")}_#{messager.id}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The signed, purpose-scoped GlobalID that filters the inbox to this
|
|
70
|
+
# stack (`GET /conversations?with=…`).
|
|
71
|
+
def with_sgid
|
|
72
|
+
Chats.inbox_with_sgid(messager)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# One messager's inbox refresh (Chats::Broadcasts.refresh_inbox_of): at
|
|
5
|
+
# most one queued per messager, standing for every write that commits
|
|
6
|
+
# before it starts. A messager deleted before it ran has no inbox.
|
|
7
|
+
class InboxRefreshJob < ActiveJob::Base
|
|
8
|
+
discard_on ActiveJob::DeserializationError
|
|
9
|
+
|
|
10
|
+
def perform(messager)
|
|
11
|
+
Chats::Broadcasts.refresh_inbox_now(messager)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/chats/macros.rb
CHANGED
|
@@ -17,8 +17,40 @@ module Chats
|
|
|
17
17
|
# lives in Chats::Messager / Chats::ChatSubject so it's discoverable,
|
|
18
18
|
# testable, and `include`-able directly when a host prefers that style.
|
|
19
19
|
module Macros
|
|
20
|
-
|
|
20
|
+
# Options (all optional; the defaults are 0.1.x behaviour):
|
|
21
|
+
#
|
|
22
|
+
# notifications: false this messager is never notifiable — a support
|
|
23
|
+
# desk, a bot, an org mailbox. `Participant#
|
|
24
|
+
# notifiable_for?` says no, so hosts stop
|
|
25
|
+
# branching on class in every notifier.
|
|
26
|
+
# blockable: false block/report affordances don't apply to it, so
|
|
27
|
+
# the bundled views hide them.
|
|
28
|
+
# inbox: :grouped every direct conversation with this messager
|
|
29
|
+
# stacks into ONE inbox row (see Chats::Inbox).
|
|
30
|
+
# group_path: ->(viewer) { } where that stacked row links to; defaults
|
|
31
|
+
# to the filtered inbox (`?with=<sgid>`).
|
|
32
|
+
# verified: true this messager is an OFFICIAL account — a support
|
|
33
|
+
# desk, an organization, a brand. The bundled views
|
|
34
|
+
# mark its name with the rosette everyone already
|
|
35
|
+
# reads as "verified". Strictly true/false: a badge
|
|
36
|
+
# is a trust claim, so a stray "false" string must
|
|
37
|
+
# raise rather than quietly verify.
|
|
38
|
+
#
|
|
39
|
+
# class Desk < ApplicationRecord
|
|
40
|
+
# acts_as_messager notifications: false, blockable: false, inbox: :grouped,
|
|
41
|
+
# verified: true
|
|
42
|
+
# end
|
|
43
|
+
def acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil,
|
|
44
|
+
verified: false)
|
|
21
45
|
include Chats::Messager
|
|
46
|
+
|
|
47
|
+
self.chat_options = Chats::Messager.normalize_options(
|
|
48
|
+
notifications: notifications,
|
|
49
|
+
blockable: blockable,
|
|
50
|
+
inbox: inbox,
|
|
51
|
+
group_path: group_path,
|
|
52
|
+
verified: verified
|
|
53
|
+
)
|
|
22
54
|
end
|
|
23
55
|
|
|
24
56
|
def acts_as_chat_subject
|
|
@@ -31,5 +31,28 @@ module Chats
|
|
|
31
31
|
def chat_subject_label
|
|
32
32
|
"#{self.class.model_name.human} #{id}"
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
# Whether conversations ABOUT this record still accept messages. The
|
|
36
|
+
# subject already owns the conversation's meaning, so it owns its
|
|
37
|
+
# openness too — a closed ticket, a delivered order, an archived listing:
|
|
38
|
+
#
|
|
39
|
+
# class Ticket < ApplicationRecord
|
|
40
|
+
# acts_as_chat_subject
|
|
41
|
+
# def chat_locked? = closed?
|
|
42
|
+
# def chat_locked_notice = "This ticket is closed. Reply to reopen it."
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# System messages are exempt (the host can always post "Ticket closed"),
|
|
46
|
+
# and locking is a WRITE rule only: the thread stays readable. See
|
|
47
|
+
# Chats::Conversation#locked?.
|
|
48
|
+
def chat_locked?
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The sentence shown where the composer would be. Nil falls back to the
|
|
53
|
+
# gem's localized "This conversation is closed."
|
|
54
|
+
def chat_locked_notice
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
34
57
|
end
|
|
35
58
|
end
|