chats 0.4.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +276 -0
- data/README.md +96 -9
- data/app/assets/stylesheets/chats.css +596 -0
- data/app/controllers/chats/conversations_controller.rb +71 -5
- data/app/helpers/chats/engine_helper.rb +75 -2
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1070 -0
- data/app/javascript/chats/thread_controller.js +72 -6
- 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/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +172 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +2 -1
- 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 +130 -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/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +18 -0
- data/config/locales/es.yml +18 -0
- data/config/routes.rb +5 -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 +76 -10
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +19 -0
- data/lib/chats/models/message.rb +32 -8
- 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 +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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
115
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
151
|
-
recent = base_relation.direct.where(id: stacked_seats).except(:includes).select(:id).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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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:
|
|
252
|
-
unread:
|
|
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
|
-
#
|
|
197
|
-
#
|
|
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/models/message.rb
CHANGED
|
@@ -115,13 +115,15 @@ module Chats
|
|
|
115
115
|
validate :body_or_files_required
|
|
116
116
|
validate :body_must_fit_length_limit
|
|
117
117
|
validate :sender_must_be_active_participant, on: :create
|
|
118
|
-
validate :sender_must_not_be_blocked, on: :create
|
|
119
118
|
validate :conversation_must_not_be_locked, on: :create
|
|
120
119
|
validate :author_must_be_persisted
|
|
121
120
|
# Checked at the write that SETS it, not on every save: an edit must
|
|
122
121
|
# never fail over an action nobody touched.
|
|
123
122
|
validate :action_must_be_well_formed, if: :will_save_change_to_action?
|
|
124
123
|
validate :files_must_be_allowed
|
|
124
|
+
# Last on purpose: the host's policy is asked only of a send that is
|
|
125
|
+
# otherwise valid (see the method).
|
|
126
|
+
validate :sender_must_still_be_allowed, on: :create
|
|
125
127
|
|
|
126
128
|
after_create :register_on_conversation
|
|
127
129
|
after_create_commit :broadcast_created
|
|
@@ -337,15 +339,37 @@ module Chats
|
|
|
337
339
|
errors.add(:sender, :not_a_participant) unless conversation.participant?(sender)
|
|
338
340
|
end
|
|
339
341
|
|
|
340
|
-
#
|
|
341
|
-
# block placed mid-conversation
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
-
|
|
345
|
-
|
|
342
|
+
# Who may talk to whom is decided at the WRITE, not just when the
|
|
343
|
+
# conversation was opened: a block placed mid-conversation stops the very
|
|
344
|
+
# next send, and so does the host's `can_message` policy turning false —
|
|
345
|
+
# an account banned, a listing closed, a membership ended. The pair was
|
|
346
|
+
# allowed when the thread began; what matters is whether it still is.
|
|
347
|
+
# Direct threads only — see Conversation.excluding_blocked_for for the
|
|
348
|
+
# group rationale. Two errors, because they read differently to the
|
|
349
|
+
# sender: `:blocked` (either side blocked the other) and `:not_allowed`
|
|
350
|
+
# (the policy said no).
|
|
351
|
+
#
|
|
352
|
+
# Asked last, and only of a send that is otherwise valid: a stranger to
|
|
353
|
+
# the conversation, a locked thread or an empty body is refused before
|
|
354
|
+
# the host's policy runs, so the policy is never consulted about a
|
|
355
|
+
# message that could not be sent anyway (it may be expensive, and it
|
|
356
|
+
# runs on every direct send now). A policy must not raise: an
|
|
357
|
+
# exception escapes the send like any other host error.
|
|
358
|
+
#
|
|
359
|
+
# A direct thread with nobody on the other side — the counterpart's
|
|
360
|
+
# account was destroyed and its seat went with it — takes no messages:
|
|
361
|
+
# there is no one the policy could allow.
|
|
362
|
+
def sender_must_still_be_allowed
|
|
363
|
+
return if system? || sender.nil? || conversation.nil? || !conversation.direct? || errors.any?
|
|
346
364
|
|
|
347
365
|
other = conversation.other_participants(sender).first&.messager
|
|
348
|
-
|
|
366
|
+
if other.nil?
|
|
367
|
+
errors.add(:base, :not_allowed)
|
|
368
|
+
elsif Chats.blocked_between?(sender, other)
|
|
369
|
+
errors.add(:base, :blocked)
|
|
370
|
+
elsif !Chats.config.can_message.call(sender, other)
|
|
371
|
+
errors.add(:base, :not_allowed)
|
|
372
|
+
end
|
|
349
373
|
end
|
|
350
374
|
|
|
351
375
|
# Authorship is a signature, not a conversation seat. A host may keep
|
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.0
|
|
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,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
|