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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1037 -0
  3. data/README.md +621 -40
  4. data/app/assets/stylesheets/chats.css +906 -2
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +396 -86
  7. data/app/controllers/chats/messages_controller.rb +35 -13
  8. data/app/controllers/chats/reactions_controller.rb +14 -0
  9. data/app/helpers/chats/engine_helper.rb +230 -7
  10. data/app/javascript/chats/composer_controller.js +14 -1
  11. data/app/javascript/chats/dock_controller.js +1120 -0
  12. data/app/javascript/chats/recovery.js +539 -0
  13. data/app/javascript/chats/refresh_inbox_controller.js +76 -0
  14. data/app/javascript/chats/split_controller.js +131 -0
  15. data/app/javascript/chats/thread_controller.js +322 -60
  16. data/app/javascript/chats/unread_badge_controller.js +24 -0
  17. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  18. data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
  19. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  20. data/app/views/chats/conversations/_group.html.erb +61 -0
  21. data/app/views/chats/conversations/_history.html.erb +54 -0
  22. data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
  23. data/app/views/chats/conversations/_thread.html.erb +147 -0
  24. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  25. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  26. data/app/views/chats/conversations/dock.html.erb +40 -0
  27. data/app/views/chats/conversations/index.html.erb +142 -15
  28. data/app/views/chats/conversations/pane.html.erb +14 -0
  29. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  30. data/app/views/chats/conversations/show.html.erb +18 -134
  31. data/app/views/chats/conversations/window.html.erb +26 -0
  32. data/app/views/chats/dock/_badge.html.erb +12 -0
  33. data/app/views/chats/dock/_dock.html.erb +139 -0
  34. data/app/views/chats/dock/_signal.html.erb +26 -0
  35. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  36. data/app/views/chats/dock/_window.html.erb +23 -0
  37. data/app/views/chats/dock/_window_header.html.erb +75 -0
  38. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  39. data/app/views/chats/messages/_action.html.erb +15 -0
  40. data/app/views/chats/messages/_composer.html.erb +84 -56
  41. data/app/views/chats/messages/_message.html.erb +43 -15
  42. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  43. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  44. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  45. data/config/importmap.rb +10 -1
  46. data/config/locales/en.yml +40 -0
  47. data/config/locales/es.yml +40 -0
  48. data/config/routes.rb +8 -0
  49. data/context7.json +4 -0
  50. data/docs/PRD.md +1 -1
  51. data/docs/campfire_review.md +1 -1
  52. data/docs/screenshots/composer-actions.png +0 -0
  53. data/docs/screenshots/dock-bar.png +0 -0
  54. data/docs/screenshots/dock-desktop.png +0 -0
  55. data/docs/screenshots/dock-phone-button.png +0 -0
  56. data/docs/screenshots/dock-phone-panel.png +0 -0
  57. data/docs/screenshots/dock-phone-window.png +0 -0
  58. data/docs/screenshots/inbox.png +0 -0
  59. data/docs/screenshots/split-inbox-empty.png +0 -0
  60. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  61. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  62. data/docs/screenshots/split-inbox.png +0 -0
  63. data/docs/screenshots/system-note-action.png +0 -0
  64. data/docs/screenshots/thread-actions-phone.png +0 -0
  65. data/gemfiles/rails_7.1.gemfile +3 -0
  66. data/gemfiles/rails_7.2.gemfile +3 -0
  67. data/gemfiles/rails_8.1.gemfile +3 -0
  68. data/lib/chats/action.rb +85 -0
  69. data/lib/chats/broadcasts.rb +178 -17
  70. data/lib/chats/configuration.rb +115 -1
  71. data/lib/chats/engine.rb +29 -7
  72. data/lib/chats/errors.rb +16 -0
  73. data/lib/chats/inbox.rb +363 -0
  74. data/lib/chats/inbox_group.rb +75 -0
  75. data/lib/chats/inbox_refresh_job.rb +14 -0
  76. data/lib/chats/macros.rb +33 -1
  77. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  78. data/lib/chats/models/concerns/messager.rb +122 -6
  79. data/lib/chats/models/conversation.rb +153 -19
  80. data/lib/chats/models/message.rb +292 -21
  81. data/lib/chats/models/participant.rb +67 -0
  82. data/lib/chats/models/reaction.rb +18 -0
  83. data/lib/chats/send_rate_limited.rb +30 -0
  84. data/lib/chats/subscribers.rb +156 -0
  85. data/lib/chats/version.rb +1 -1
  86. data/lib/chats.rb +123 -15
  87. data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
  88. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  89. data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
  90. data/lib/generators/chats/templates/initializer.rb +125 -14
  91. data/lib/generators/chats/upgrade_generator.rb +79 -0
  92. data/lib/generators/chats/views_generator.rb +3 -2
  93. metadata +51 -2
@@ -55,11 +55,52 @@ module Chats
55
55
  Chats.config.layout
56
56
  end
57
57
 
58
- # Conversations are ALWAYS resolved through the viewer's own inbox
59
- # relation: not-a-participant, left, or blocked-counterpart threads all
60
- # come back as a plain 404 — existence is never leaked to outsiders.
58
+ # Conversations are ALWAYS resolved through relations that only hold
59
+ # what this viewer may open (conversation_lookup_scopes): not a
60
+ # participant, left, or a blocked counterpart all come back as a plain
61
+ # 404, the same 404 as an id that doesn't exist — existence is never
62
+ # leaked to outsiders. Every engine endpoint (the thread, a dock window,
63
+ # sending, editing, reacting, reading) looks a conversation up here.
64
+ #
65
+ # The first scope that has it wins; later scopes are asked only on a
66
+ # miss. Errors a scope raises are not rescued: a broken product scope
67
+ # must fail loudly, never turn into a quiet 404.
61
68
  def find_conversation(id = params[:id])
62
- chats_current_messager.chats.find(id)
69
+ conversation_lookup_scopes.each do |scope|
70
+ conversation = scope.find_by(id: id)
71
+ return conversation if conversation
72
+ end
73
+
74
+ raise ActiveRecord::RecordNotFound.new(
75
+ "Couldn't find Chats::Conversation with 'id'=#{id}", Chats::Conversation.name, "id", id
76
+ )
77
+ end
78
+
79
+ protected
80
+
81
+ # Where find_conversation looks, in order: by default the viewer's own
82
+ # inbox relation (`Messager#chats`: their active seats, blocks and
83
+ # `config.inbox_scope` applied).
84
+ #
85
+ # THE extension point for a conversation that must open although the
86
+ # inbox hides it. A product gem built on chats (support_desk: a
87
+ # requester's own ticket, which the host's inbox_scope keeps out of the
88
+ # inbox) prepends a module that appends its own relation:
89
+ #
90
+ # module SupportDesk::ConversationLookup
91
+ # protected
92
+ #
93
+ # def conversation_lookup_scopes
94
+ # super + [SupportDesk::Ticket.conversations_of(chats_current_messager)]
95
+ # end
96
+ # end
97
+ # Chats::ApplicationController.prepend(SupportDesk::ConversationLookup)
98
+ #
99
+ # Each scope must hold only conversations this viewer may open: the
100
+ # union of the scopes IS the authorization. Nothing the scopes don't
101
+ # name becomes reachable.
102
+ def conversation_lookup_scopes
103
+ [chats_current_messager.chats]
63
104
  end
64
105
  end
65
106
  end
@@ -2,37 +2,80 @@
2
2
 
3
3
  module Chats
4
4
  # The inbox (index), the thread (show), starting conversations from host
5
- # pages (create), and the per-member actions (read/typing/leave/mute).
5
+ # pages (create), the messaging dock's recent list (dock), and the
6
+ # per-member actions (read/typing/leave/mute).
6
7
  class ConversationsController < ApplicationController
8
+ # The most rows the dock's recent list shows, whatever `recent:` asks.
9
+ DOCK_RECENT_LIMIT = 50
10
+ # How far before its list of what exists a catch-up stops naming
11
+ # deletions (see #present_messages).
12
+ PRESENT_MARGIN = 10.seconds
13
+ # The most ids a catch-up cursor may name, and the longest id (a UUID is
14
+ # 36): a client past either gets its history re-rendered instead.
15
+ CURSOR_IDS_LIMIT = 200
16
+ CURSOR_ID_LENGTH = 64
17
+ # The two-pane messaging page's conversation frame (index.html.erb): a
18
+ # request from it is answered with the pane alone (pane.html.erb).
19
+ PANE_FRAME = "chats_conversation"
20
+
7
21
  before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
22
+ helper_method :chats_counterpart, :chats_two_panes?, :chats_inbox_filter
8
23
 
9
24
  # The inbox. Everything is preloaded/batched so rendering N rows costs a
10
25
  # constant number of queries (conversations + last messages + participants
11
26
  # + one grouped unread-count query — see Conversation.unread_counts_for).
27
+ # Rows are Conversation | InboxGroup: stacking, search and the `?with=`
28
+ # filter all live in Chats::Inbox, so this action stays three lines.
12
29
  def index
13
- @conversations = chats_current_messager.chats
14
- .includes(:last_message, :subject, participants: :messager)
15
- .limit(200)
16
- @conversations = apply_search(@conversations)
17
- @unread_counts = Chats::Conversation.unread_counts_for(chats_current_messager, @conversations)
30
+ # The dock's search form posts here, so that without JavaScript it
31
+ # lands on the full inbox's results; from the dock's frame, the answer
32
+ # is the dock's own list.
33
+ return dock if turbo_frame_request_id == "chats_dock_list"
34
+
35
+ load_inbox
36
+ end
37
+
38
+ # The messaging dock's recent list (`chats_dock`): the first rows of the
39
+ # viewer's inbox — the same rows, stacks and search, through the same
40
+ # Chats::Inbox — rendered into the dock's `chats_dock_list` frame. The
41
+ # frame asks for `?recent=` rows (the helper's `recent:` option).
42
+ def dock
43
+ @inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], limit: dock_recent)
44
+ @rows = @inbox.rows
45
+ @unread_counts = @inbox.unread_counts
46
+
47
+ render :dock, layout: false
48
+ end
49
+
50
+ # The viewer's unread badges as a Turbo Stream: the nav badge
51
+ # (`chats_unread_badge`) and every badge in the dock, replaced with the
52
+ # same count the badge streams send. What a page asks for when it may
53
+ # have missed them (chats/recovery: a reconnect, a return to a hidden
54
+ # page, a restored page). Reads nothing, writes nothing.
55
+ def badge
56
+ render :badge, formats: :turbo_stream, locals: { count: chats_current_messager.unread_chats_count }
18
57
  end
19
58
 
20
59
  # The thread. Renders the LATEST page of messages; older pages stream in
21
60
  # through a lazy Turbo Frame chain (keyset-paginated — see
22
61
  # Message.before_message and _messages_page.html.erb).
62
+ #
63
+ # `?window=1` from a Turbo frame is the same thread as a floating dock
64
+ # window: the window template (its compact header around the same
65
+ # thread body), no layout.
66
+ #
67
+ # With two panes (`config.inbox_layout = :split`, the default) the
68
+ # thread is the conversation pane of the messaging page: the whole page
69
+ # (the inbox's template, with the pane open), or, asked from that page's
70
+ # own conversation frame (a row clicked on a wide screen), the pane
71
+ # alone. `:stacked` renders the thread page.
23
72
  def show
24
73
  @participant = @conversation.participant_for(chats_current_messager)
25
74
 
26
75
  anchor = params[:before].present? ? @conversation.messages.find_by(id: params[:before]) : nil
27
- scope = @conversation.messages.includes(:sender, :reactions)
28
- scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
76
+ scope = rendered_messages
29
77
  scope = scope.before_message(anchor) if anchor
30
-
31
- # Fetch newest-first + reverse so "the last N messages" render in
32
- # chronological order. One extra record peeks whether older pages exist.
33
- page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
34
- @more_messages = page.size > Chats.config.messages_per_page
35
- @messages = page.first(Chats.config.messages_per_page).reverse
78
+ @messages, @more_messages = latest_page_of(scope)
36
79
 
37
80
  if anchor
38
81
  # Older-page request from the pagination frame: render just the page.
@@ -40,51 +83,73 @@ module Chats
40
83
  locals: { conversation: @conversation, messages: @messages, more: @more_messages }
41
84
  else
42
85
  # The «new messages» divider: computed BEFORE read! advances the
43
- # horizon (after it, nothing is unread anymore). Anchored to the
44
- # oldest unread bubble on the rendered page; when the backlog runs
45
- # deeper than one page it pins to the top of the page instead —
46
- # the scroll-up frame chain holds the rest.
47
- if @participant&.unread?
48
- @first_unread_id = @participant.unread_messages.where(id: @messages.map(&:id)).oldest_first.pick(:id) ||
49
- @messages.first&.id
50
- end
86
+ # horizon (after it, nothing is unread anymore).
87
+ @first_unread_id = first_unread_id(@participant, @messages)
51
88
 
52
- # Opening the thread reads it. (Live appends while the thread stays
53
- # open are read via the thread controller's POST to #read.)
54
- @participant&.read!
89
+ if dock_window?
90
+ # A window does NOT read on render: it may be restored minimized
91
+ # after a reload, and a header nobody has looked at must not
92
+ # swallow the unread badge. The thread controller posts #read once
93
+ # the window is actually on screen (`read-pending`).
94
+ render :window, layout: false
95
+ else
96
+ # Opening the thread reads it. (Live appends while the thread
97
+ # stays open are read via the thread controller's POST to #read.)
98
+ # A render the server doesn't read is marked `read-pending`: the
99
+ # thread controller reads it once it is on screen.
100
+ @read_pending = !reads_on_render?
101
+ @participant&.read! unless @read_pending
102
+ render_split_page if chats_two_panes?
103
+ end
55
104
  end
56
105
  end
57
106
 
58
- # Stale-thread catch-up: appends messages created — and replaces ones
59
- # edited/tombstoned — since the newest `updated_at` the client has
60
- # rendered (`?since=` in ms). The thread controller calls this when the
61
- # tab wakes from a long sleep or its Turbo Stream subscription
62
- # reconnects, i.e. whenever broadcasts may have been missed. Mobile
63
- # WebViews suspend WebSockets aggressively, so without this a
64
- # backgrounded chat silently loses messages until a manual reload.
65
- # Pattern from Basecamp's Campfire (Rooms::RefreshesController):
66
- # https://github.com/basecamp/once-campfire
107
+ # Stale-thread catch-up, on the client's cursors (see Message's catch-up
108
+ # scopes): `after=` µs and `after_ids=`, its newest creation and the ids
109
+ # it has at that moment (0 and none for an empty thread); `changed=` µs
110
+ # and `changed_ids=`, its newest change; `from=` µs, its oldest bubble's
111
+ # creation. Bubbles ejected before they carried these send `since=` ms
112
+ # alone, and are answered as they always were. The thread
113
+ # controller calls this whenever chats/recovery says the stream may have
114
+ # missed something: a reconnect, a return after 10 seconds hidden, the
115
+ # network back, a restored page. Mobile WebViews suspend WebSockets
116
+ # aggressively, so without this a backgrounded chat silently loses
117
+ # messages until a manual reload. Pattern from Basecamp's Campfire
118
+ # (Rooms::RefreshesController): https://github.com/basecamp/once-campfire
119
+ #
120
+ # The answer touches the history only, never the composer or its draft:
121
+ # messages created since are appended, ones changed since (edits,
122
+ # tombstones, reactions) replaced, the read state replaced, and — under
123
+ # `deletion: :hard` — the bubbles that no longer exist removed. Reads
124
+ # nothing: catching up is not reading (the controller posts #read once
125
+ # the viewer can see the new messages).
126
+ #
127
+ # `render turbo_stream:`, NOT `render html: … content_type:` — the
128
+ # latter forces text/html and silently ignores the content type, so the
129
+ # body says <turbo-stream> while the response says it isn't one.
67
130
  def refresh
68
- head :no_content and return if params[:since].blank?
131
+ head :no_content and return if params[:changed].blank? && params[:since].blank?
69
132
 
70
- since = Time.zone.at(0, params[:since].to_i, :millisecond)
71
- scope = @conversation.messages.includes(:sender, :reactions)
72
- scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
73
-
74
- @new_messages = scope.created_since(since).oldest_first.limit(Chats.config.messages_per_page + 1).to_a
75
-
76
- # A backlog deeper than one page would mean splicing an arbitrary
77
- # amount of history through surgical appends; a Turbo 8 page refresh
78
- # (morph + scroll preservation) re-renders the latest page + frame
79
- # chain correctly instead. Raw tag rather than `turbo_stream.refresh`
80
- # so we don't depend on turbo-rails ≥ 2.0 helpers.
81
- if @new_messages.size > Chats.config.messages_per_page
82
- render html: '<turbo-stream action="refresh"></turbo-stream>'.html_safe,
83
- content_type: "text/vnd.turbo-stream.html"
84
- return
85
- end
133
+ # A client whose cursors name more than the limit (a busy thread whose
134
+ # live bubbles piled up since it last caught up, or a forged request)
135
+ # is answered with what is cheap at any size: the latest page.
136
+ return render_from_scratch if cursors_over_limit?
86
137
 
87
- @updated_messages = scope.updated_since(since)
138
+ scope = rendered_messages
139
+ new_messages, @updated_messages = catch_up_on(scope)
140
+ @new_messages = new_messages.oldest_first.limit(Chats.config.messages_per_page + 1).to_a
141
+
142
+ # More than a page behind: splicing an arbitrary backlog through
143
+ # surgical appends would leave a hole in the scroll-up chain. Re-render
144
+ # the history at the latest page instead, exactly as opening the thread
145
+ # does, frame chain included. A thread view ejected before the history
146
+ # had its id (no `history=1`) gets the page refresh it always got;
147
+ # `request_id: nil`, because Turbo skips a refresh tagged with a request
148
+ # of its own, and this one answers the client's own catch-up.
149
+ return render_from_scratch if @new_messages.size > Chats.config.messages_per_page
150
+
151
+ @caught_up = caught_up_cursors if params[:changed].present?
152
+ @present = present_messages if Chats.config.deletion == :hard && params[:from].present?
88
153
  render "chats/conversations/refresh", formats: :turbo_stream
89
154
  end
90
155
 
@@ -93,6 +158,12 @@ module Chats
93
158
  # `chat_button_to` helper — unforgeable and purpose-scoped, so raw
94
159
  # polymorphic params never reach `GlobalID::Locator`. Policy and block
95
160
  # checks still run inside `chat_with` (defense in depth).
161
+ #
162
+ # `dock=1` (`chat_button_to …, dock: true`) answers a Turbo Stream that
163
+ # appends the conversation's floating window to the dock instead of
164
+ # navigating. Same id, so a window already open is replaced, not
165
+ # doubled; on a page without the dock the stream has no target and does
166
+ # nothing. Without Turbo it is the plain redirect.
96
167
  def create
97
168
  recipient = locate_signed!(params.require(:recipient_sgid), purpose: :chats_recipient)
98
169
  raise ActiveRecord::RecordNotFound unless Chats.messager_class?(recipient.class)
@@ -101,7 +172,16 @@ module Chats
101
172
  locate_signed!(params[:subject_sgid], purpose: :chats_subject)
102
173
 
103
174
  conversation = chats_current_messager.chat_with(recipient, about: subject)
104
- redirect_to conversation_path(conversation)
175
+
176
+ if params[:dock].present? && request.format.turbo_stream?
177
+ render turbo_stream: turbo_stream.append(
178
+ "chats_dock_windows",
179
+ partial: "chats/dock/window",
180
+ locals: { conversation: conversation, focus: true }
181
+ )
182
+ else
183
+ redirect_to conversation_path(conversation)
184
+ end
105
185
  rescue Chats::BlockedError
106
186
  # Fallback is the INBOX (engine root) — inside the engine, `root_path`
107
187
  # already resolves there, never to the host root.
@@ -152,47 +232,277 @@ module Chats
152
232
  @conversation = find_conversation
153
233
  end
154
234
 
155
- def locate_signed!(sgid, purpose:)
156
- GlobalID::Locator.locate_signed(sgid, for: purpose) || raise(ActiveRecord::RecordNotFound)
235
+ # The inbox's rows, for the inbox page and for the two-pane page.
236
+ def load_inbox
237
+ @inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
238
+ @rows = @inbox.rows
239
+ @unread_counts = @inbox.unread_counts
240
+ # Back-compat for inboxes ejected under 0.1.x, which loop over
241
+ # @conversations: they keep rendering the flat list (no stacking, i.e.
242
+ # exactly what they rendered before). Re-eject or delete your copy to
243
+ # get the stacked rows.
244
+ @conversations = @inbox.conversations
157
245
  end
158
246
 
159
- # Partial, case-insensitive matching across the inbox metadata users can
160
- # actually see: participant names, conversation titles, subject labels,
161
- # and message bodies. The inbox is capped at 200 rows, so metadata is
162
- # filtered portably in Ruby from the already-preloaded objects while the
163
- # potentially larger message-body set stays in SQL. No PostgreSQL-only
164
- # full-text dependency is needed for this scale.
165
- def apply_search(conversations)
166
- return conversations unless Chats.config.search
167
-
168
- query = params[:q].to_s.strip
169
- return conversations if query.empty?
170
-
171
- loaded = conversations.to_a
172
- normalized_query = query.downcase
173
- pattern = "%#{Chats::Conversation.sanitize_sql_like(query.downcase)}%"
174
- message_match_ids =
175
- if Chats.config.encrypt_messages
176
- []
177
- else
178
- Chats::Message.where(conversation_id: loaded.map(&:id), deleted_at: nil)
179
- .where("LOWER(chats_messages.body) LIKE ?", pattern)
180
- .distinct
181
- .pluck(:conversation_id)
182
- end
247
+ # The open conversation on the two-pane page: the pane alone for the
248
+ # page's own conversation frame, else the whole page around it (any
249
+ # other frame of the page, the search's results among them, finds its
250
+ # own part in it).
251
+ #
252
+ # The pane alone comes in turbo-rails' minimal frame layout, always (even
253
+ # under a `config.layout`): nothing of the host's page is rendered for
254
+ # Turbo to throw away. Its title travels on the pane (`data-title`,
255
+ # chats_page_title), since Turbo leaves <head> alone on a frame visit.
256
+ def render_split_page
257
+ return render :pane, layout: "turbo_rails/frame" if pane_request?
258
+
259
+ load_inbox
260
+ render :index
261
+ end
262
+
263
+ def pane_request?
264
+ turbo_frame_request_id == PANE_FRAME
265
+ end
266
+
267
+ # Whether the messaging page is two panes: `config.inbox_layout = :split`
268
+ # (the default), unless this app ejected its views before there were
269
+ # two panes. `rails g chats:views` under 0.5 copied an inbox and a
270
+ # thread page and no conversation pane: that app keeps its own separate
271
+ # pages, rendered and read exactly as before, until it ejects again (or
272
+ # deletes its copies). Views ejected since 0.6 carry the pane, and are
273
+ # two panes like the gem's own.
274
+ def chats_two_panes?
275
+ return @chats_two_panes if defined?(@chats_two_panes)
276
+
277
+ @chats_two_panes = Chats.config.split_inbox? && !views_ejected_before_two_panes?
278
+ end
279
+
280
+ # The list's filter on the two-pane page: its search (`q`) or the stack
281
+ # it shows (`with`). A row opened in the pane carries it into its URL,
282
+ # /messages/:id?with=…, because that URL is what the list's live
283
+ # refreshes re-request: without it, the next refresh would bring back
284
+ # the whole inbox under a list that was filtered. The back link to the
285
+ # list keeps it too.
286
+ def chats_inbox_filter
287
+ { q: params[:q], with: params[:with] }.select { |_, value| value.is_a?(String) && value.present? }
288
+ end
289
+
290
+ def views_ejected_before_two_panes?
291
+ (own_view?("index") || own_view?("show")) && !own_view?("conversation_pane", partial: true)
292
+ end
293
+
294
+ # Whether the app renders its own copy of one of these views, not the gem's.
295
+ def own_view?(name, partial: false)
296
+ template = lookup_context.find_all(name, ["chats/conversations"], partial).first
297
+ template.present? && !template.identifier.to_s.start_with?(Chats::Engine.root.join("app/views").to_s)
298
+ end
299
+
300
+ # Whether showing the conversation reads it, there and then.
301
+ #
302
+ # Never a prefetch (Turbo 8 fetches a link the pointer rests on, which
303
+ # is nobody reading). On the thread page, everything else: opening it
304
+ # reads it.
305
+ #
306
+ # On the two-pane page, the viewer opening it: a row clicked (the
307
+ # pane's frame) or the page loaded by the browser itself (a link from
308
+ # elsewhere, a reload, no JavaScript). NOT a Turbo visit of the page,
309
+ # because one of those is the inbox's own refresh: every live update of
310
+ # the list re-requests this page, maybe in a hidden tab, and a read
311
+ # there would say "Seen" to the sender while nobody looks — and, since
312
+ # a read refreshes the reader's inbox, refresh the page again, forever.
313
+ # Turbo marks each of its requests (X-Turbo-Request-Id) and a refresh
314
+ # looks like any other visit, so a Turbo visit renders the pane unread
315
+ # and its thread reads it once it's on screen. A refresh's pane never
316
+ # gets that far: the page keeps its permanent one, and drops the new.
317
+ def reads_on_render?
318
+ return false if prefetch?
319
+ return true unless chats_two_panes?
320
+
321
+ pane_request? || request.headers["X-Turbo-Request-Id"].blank?
322
+ end
183
323
 
184
- loaded.select do |conversation|
185
- message_match_ids.include?(conversation.id) ||
186
- searchable_metadata(conversation).downcase.include?(normalized_query)
324
+ def time_from_ms(value)
325
+ Time.zone.at(0, value.to_i, :millisecond)
326
+ end
327
+
328
+ def time_from_us(value)
329
+ Time.zone.at(0, value.to_i, :microsecond)
330
+ end
331
+
332
+ # What to append and what to replace: on the two microsecond cursors,
333
+ # or on the one millisecond cursor of bubbles ejected before them.
334
+ def catch_up_on(scope)
335
+ if params[:changed].present?
336
+ created_at, created_seen = cursor(:after)
337
+ updated_at, updated_seen = cursor(:changed)
338
+ [scope.created_after(created_at, seen: created_seen),
339
+ scope.created_through(created_at, seen: created_seen).updated_after(updated_at, seen: updated_seen)]
340
+ else
341
+ since = time_from_ms(params[:since])
342
+ [scope.created_since(since), scope.updated_since(since)]
187
343
  end
188
344
  end
189
345
 
190
- def searchable_metadata(conversation)
191
- participant_names = conversation.participants.filter_map do |participant|
192
- Chats.display_name_for(participant.messager) if participant.active?
346
+ # The moment and the ids seen with it, from `<name>=µs` and `<name>_ids=1,2`.
347
+ def cursor(name)
348
+ [time_from_us(params[name]), params[:"#{name}_ids"].to_s.split(",")]
349
+ end
350
+
351
+ def cursors_over_limit?
352
+ return true if params[:full].present?
353
+
354
+ %i[after_ids changed_ids].any? do |name|
355
+ ids = params[name].to_s.split(",")
356
+ ids.size > CURSOR_IDS_LIMIT || ids.any? { |id| id.size > CURSOR_ID_LENGTH }
193
357
  end
358
+ end
359
+
360
+ # Where the client stands once it has rendered this answer: what it was
361
+ # sent is now known to be on screen, so the cursors move to it. Sent
362
+ # back with the answer (the chats_caught_up action), because only an
363
+ # answer that arrived may move them.
364
+ def caught_up_cursors
365
+ after = cursor(:after).first
366
+ changed_at, changed_seen = cursor(:changed)
367
+ sent = @new_messages + @updated_messages.to_a
368
+ after = [after, *@new_messages.map(&:created_at)].max
369
+
370
+ newest = sent.map(&:updated_at).max
371
+ changed_seen = [] if newest && newest > changed_at
372
+ changed_at = [changed_at, newest].compact.max
373
+ changed_seen |= sent.select { |message| message.updated_at == changed_at }.map { |message| message.id.to_s }
374
+
375
+ cursors_tag(after, changed_at, changed_seen)
376
+ end
377
+
378
+ def cursors_tag(after, changed_at, changed_seen)
379
+ helpers.turbo_stream_action_tag(
380
+ :chats_caught_up,
381
+ target: helpers.dom_id(@conversation, :messages),
382
+ "data-after": microseconds(after), "data-changed": microseconds(changed_at),
383
+ "data-changed-ids": changed_seen.join(","),
384
+ # Which render of the thread asked: an answer for an earlier one is
385
+ # ignored (see the thread controller's caughtUp).
386
+ "data-render": params[:render].to_s.first(CURSOR_ID_LENGTH).presence
387
+ )
388
+ end
389
+
390
+ def microseconds(time)
391
+ (time.to_i * 1_000_000) + time.usec
392
+ end
393
+
394
+ # The history at its latest page, or, for a thread view ejected before
395
+ # the history had its id (no `history=1`), the page refresh it always
396
+ # got. `request_id: nil`: Turbo skips a refresh tagged with a request of
397
+ # its own, and this one answers the client's own catch-up.
398
+ def render_from_scratch
399
+ return render_latest_history if params[:history].present?
400
+
401
+ render turbo_stream: turbo_stream.refresh(request_id: nil)
402
+ end
403
+
404
+ # The conversation's messages with everything a bubble renders.
405
+ def rendered_messages
406
+ scope = @conversation.messages.includes(:sender, :reactions)
407
+ scope.respond_to?(:with_attached_files) ? scope.with_attached_files : scope
408
+ end
409
+
410
+ # The newest page of +scope+, oldest first, and whether older pages
411
+ # exist. Fetched newest-first and reversed; one extra record peeks.
412
+ def latest_page_of(scope)
413
+ page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
414
+ [page.first(Chats.config.messages_per_page).reverse, page.size > Chats.config.messages_per_page]
415
+ end
416
+
417
+ # Where the «new messages» line goes: the oldest unread bubble on the
418
+ # page; when the backlog runs deeper than one page, the top of the page
419
+ # (the scroll-up frame chain holds the rest). Nil when nothing is unread.
420
+ def first_unread_id(participant, messages)
421
+ return unless participant&.unread?
422
+
423
+ participant.unread_messages.where(id: messages.map(&:id)).oldest_first.pick(:id) || messages.first&.id
424
+ end
425
+
426
+ # The thread's history at its latest page, for a catch-up more than a
427
+ # page behind: the same messages, frame chain and «new messages» line
428
+ # the thread opens with (chats/conversations/_history).
429
+ #
430
+ # A complete render, so it carries its own cursors: the newest creation
431
+ # and the newest change on the page.
432
+ def render_latest_history
433
+ messages, more = latest_page_of(rendered_messages)
434
+ participant = @conversation.participant_for(chats_current_messager)
435
+ newest_change = messages.map(&:updated_at).max || Time.zone.at(0)
436
+ history = turbo_stream.update(
437
+ helpers.dom_id(@conversation, :history),
438
+ partial: "chats/conversations/history",
439
+ locals: { conversation: @conversation, messages: messages, more: more,
440
+ first_unread_id: first_unread_id(participant, messages),
441
+ counterpart: chats_counterpart, window: false }
442
+ )
443
+ cursors = cursors_tag(messages.map(&:created_at).max || Time.zone.at(0), newest_change,
444
+ messages.select { |message| message.updated_at == newest_change }.map { |m| m.id.to_s })
445
+
446
+ render turbo_stream: helpers.safe_join([history, cursors])
447
+ end
448
+
449
+ # Under `deletion: :hard` a deleted message leaves nothing to replace:
450
+ # the catch-up names every message from the client's oldest bubble on
451
+ # that still exists, and the client removes the bubbles not named.
452
+ # `until` sits PRESENT_MARGIN before the list is read: a message whose
453
+ # insert had not committed yet is missing from the list but may already
454
+ # have reached the client live — it must not be taken for a deleted one.
455
+ def present_messages
456
+ margin = Time.current - PRESENT_MARGIN
457
+ ids = @conversation.messages.where(created_at: time_from_us(params[:from])..).pluck(:id)
458
+
459
+ { ids: ids.map { |id| helpers.dom_id(Chats::Message.new(id: id)) },
460
+ until: (margin.to_i * 1_000_000) + margin.usec }
461
+ end
462
+
463
+ # A dock window asks for its thread from inside its own turbo frame. A
464
+ # plain visit to the same URL (no frame) still gets the page.
465
+ def dock_window?
466
+ params[:window].present? && turbo_frame_request?
467
+ end
468
+
469
+ # Turbo 8 prefetches a link when the pointer rests on it, and a pointer
470
+ # resting on an inbox row is not somebody reading the thread. The visit
471
+ # itself, if it happens, reads.
472
+ # https://turbo.hotwired.dev/handbook/drive#prefetching-links-on-hover
473
+ def prefetch?
474
+ request.headers["X-Sec-Purpose"] == "prefetch" || request.headers["Sec-Purpose"]&.include?("prefetch")
475
+ end
476
+
477
+ def dock_recent
478
+ Integer(params[:recent], exception: false)&.clamp(1, DOCK_RECENT_LIMIT) || 10
479
+ end
480
+
481
+ def locate_signed!(sgid, purpose:)
482
+ GlobalID::Locator.locate_signed(sgid, for: purpose) || raise(ActiveRecord::RecordNotFound)
483
+ end
484
+
485
+ # `?with=<signed gid>` — the inbox filtered to one counterpart (a stack's
486
+ # contents). Signed and purpose-scoped like every other polymorphic
487
+ # param the engine accepts; a tampered one is a plain 404.
488
+ def inbox_filter
489
+ return nil if params[:with].blank?
490
+
491
+ messager = locate_signed!(params[:with], purpose: :chats_inbox_with)
492
+ raise ActiveRecord::RecordNotFound unless Chats.messager_class?(messager.class)
493
+
494
+ messager
495
+ end
496
+
497
+ # The other party of a direct thread (nil for groups) — the thread header
498
+ # names them, links to their profile, and decides whether to offer the
499
+ # "see all" link back to their stack. LAZY and memoized: a group thread
500
+ # never pays for it, and a direct one pays once no matter how many of
501
+ # those three things the rendered view asks for.
502
+ def chats_counterpart
503
+ return @chats_counterpart if defined?(@chats_counterpart)
194
504
 
195
- [conversation.title, conversation.subject_label, *participant_names].compact.join(" ")
505
+ @chats_counterpart = @conversation&.counterpart_for(chats_current_messager)
196
506
  end
197
507
  end
198
508
  end