chats 0.5.0 → 0.6.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +349 -0
  3. data/README.md +91 -8
  4. data/app/assets/stylesheets/chats.css +114 -0
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +305 -63
  7. data/app/helpers/chats/engine_helper.rb +27 -4
  8. data/app/javascript/chats/dock_controller.js +54 -4
  9. data/app/javascript/chats/recovery.js +539 -0
  10. data/app/javascript/chats/refresh_inbox_controller.js +55 -65
  11. data/app/javascript/chats/split_controller.js +97 -0
  12. data/app/javascript/chats/thread_controller.js +245 -54
  13. data/app/javascript/chats/unread_badge_controller.js +24 -0
  14. data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
  15. data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
  16. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  17. data/app/views/chats/conversations/_group.html.erb +20 -4
  18. data/app/views/chats/conversations/_history.html.erb +54 -0
  19. data/app/views/chats/conversations/_thread.html.erb +29 -54
  20. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  21. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  22. data/app/views/chats/conversations/index.html.erb +91 -15
  23. data/app/views/chats/conversations/pane.html.erb +12 -0
  24. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  25. data/app/views/chats/conversations/show.html.erb +10 -65
  26. data/app/views/chats/dock/_dock.html.erb +14 -5
  27. data/app/views/chats/messages/_message.html.erb +8 -6
  28. data/chats-0.3.2.gem +0 -0
  29. data/chats-0.4.0.gem +0 -0
  30. data/chats-0.4.1.gem +0 -0
  31. data/chats-0.4.2.gem +0 -0
  32. data/chats-0.5.0.gem +0 -0
  33. data/chats-0.5.1.gem +0 -0
  34. data/config/importmap.rb +7 -0
  35. data/config/locales/en.yml +1 -0
  36. data/config/locales/es.yml +1 -0
  37. data/config/routes.rb +3 -0
  38. data/docs/screenshots/split-inbox-empty.png +0 -0
  39. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  40. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  41. data/docs/screenshots/split-inbox.png +0 -0
  42. data/lib/chats/broadcasts.rb +102 -7
  43. data/lib/chats/configuration.rb +23 -0
  44. data/lib/chats/inbox_refresh_job.rb +14 -0
  45. data/lib/chats/models/conversation.rb +38 -0
  46. data/lib/chats/models/message.rb +98 -9
  47. data/lib/chats/models/reaction.rb +13 -0
  48. data/lib/chats/version.rb +1 -1
  49. data/lib/generators/chats/templates/initializer.rb +9 -0
  50. metadata +21 -1
@@ -32,6 +32,13 @@
32
32
  --chats-bubble-text: #111827;
33
33
  --chats-radius: 1.1rem;
34
34
  --chats-max-width: 42rem;
35
+ /* The conversation list beside an open conversation (two panes). */
36
+ --chats-list-width: 320px;
37
+ /* --chats-split-height, how tall the two-pane page is, is NOT declared
38
+ here: the rules read it with the whole screen (100dvh) as the fallback,
39
+ so a host's own declaration wins wherever it is, even on a :root rule
40
+ loaded before this file (the bundled views link it in the body).
41
+ Under a host header: calc(100dvh - <the header's height>). */
35
42
 
36
43
  /* Safe areas, re-pointable by a host with its own source of truth. */
37
44
  --chats-safe-top: env(safe-area-inset-top, 0px);
@@ -133,6 +140,9 @@
133
140
  flex-shrink: 0;
134
141
  }
135
142
  .chats-badge--floating[hidden] { display: none; }
143
+ /* chats_unread_badge's wrapper (its catch-up controller): no box of its own,
144
+ so the badge sits in the host's layout exactly as if it weren't there. */
145
+ .chats-unread-badge-live { display: contents; }
136
146
 
137
147
  /* --- Empty states ------------------------------------------------------------ */
138
148
 
@@ -1004,6 +1014,110 @@ turbo-frame[id^="chats_page_"] {
1004
1014
  }
1005
1015
 
1006
1016
 
1017
+ /* ---------------------------------------------------------------------------
1018
+ 0.6.0 — two panes (config.inbox_layout = :split)
1019
+ ---------------------------------------------------------------------------
1020
+
1021
+ The messaging page as LinkedIn has it: from 700px wide (the dock's phone
1022
+ rule, and chats--split's), the list on the left and the open conversation
1023
+ on the right, both as tall as the screen, each scrolling on its own. Below
1024
+ that, one column, exactly as the separate pages: /messages is the list,
1025
+ /messages/:id the thread. Every request renders both panes; this media
1026
+ query alone picks, so it holds before JavaScript runs and without it.
1027
+
1028
+ .chats-split the page (.chats.chats-inbox too)
1029
+ .chats-split__list the list's <nav>
1030
+ .chats-split__conversation the `chats_conversation` frame
1031
+ .chats-split__pane a conversation in it (section)
1032
+ .chats-split__thread its permanent wrapper, around .chats-thread--pane
1033
+ .chats-split__empty no conversation open (--inbox: no conversations at all)
1034
+
1035
+ The page is as wide as the list plus a thread page (--chats-max-width),
1036
+ so the conversation reads at the width it always had, and as tall as
1037
+ --chats-split-height (the screen; a host with its own header above the
1038
+ page sets what is left: calc(100dvh - 4rem)). */
1039
+ .chats-split {
1040
+ max-width: calc(var(--chats-list-width) + var(--chats-max-width));
1041
+ /* The list alone (a phone): at least what is left of the screen, never
1042
+ the shell's whole 100dvh, which would run past it under a host header. */
1043
+ min-height: var(--chats-split-height, 100dvh);
1044
+ }
1045
+ .chats-split__list { display: flex; flex-direction: column; min-width: 0; }
1046
+ .chats-split__conversation {
1047
+ display: flex;
1048
+ flex-direction: column;
1049
+ min-width: 0;
1050
+ min-height: 0;
1051
+ }
1052
+ .chats-split__pane,
1053
+ .chats-split__thread {
1054
+ flex: 1;
1055
+ min-width: 0;
1056
+ min-height: 0;
1057
+ display: flex;
1058
+ flex-direction: column;
1059
+ }
1060
+ /* The thread page's body, inside the pane: no shell of its own (the page is
1061
+ the .chats shell), and the pane's height, not the screen's. */
1062
+ .chats-thread--pane {
1063
+ flex: 1;
1064
+ min-height: 0;
1065
+ height: auto;
1066
+ display: flex;
1067
+ flex-direction: column;
1068
+ overflow: hidden;
1069
+ }
1070
+ .chats-split__empty { margin: auto; }
1071
+ .chats-row--current .chats-row__link {
1072
+ background: var(--chats-surface);
1073
+ box-shadow: inset 3px 0 0 var(--chats-accent);
1074
+ }
1075
+
1076
+ @media (min-width: 700px) {
1077
+ .chats-split {
1078
+ box-sizing: border-box;
1079
+ flex-direction: row;
1080
+ height: var(--chats-split-height, 100dvh);
1081
+ min-height: 0;
1082
+ overflow: hidden;
1083
+ /* Its edges, where a page wider than it shows the host's background. */
1084
+ border-inline: 1px solid var(--chats-border);
1085
+ }
1086
+ .chats-split__list {
1087
+ box-sizing: border-box;
1088
+ flex: 0 0 var(--chats-list-width);
1089
+ width: var(--chats-list-width);
1090
+ border-right: 1px solid var(--chats-border);
1091
+ }
1092
+ /* The rows scroll; the title and the search stay. */
1093
+ .chats-split__list > turbo-frame {
1094
+ display: block;
1095
+ flex: 1;
1096
+ min-height: 0;
1097
+ overflow-y: auto;
1098
+ overscroll-behavior-y: contain;
1099
+ }
1100
+ .chats-split__conversation { flex: 1 1 0; }
1101
+ /* The list is right there: no way back to it. */
1102
+ .chats-split .chats-thread__back { display: none; }
1103
+ }
1104
+
1105
+ @media (max-width: 699.98px) {
1106
+ /* A conversation open: the page is the thread, as tall as the screen. */
1107
+ .chats-split:has(.chats-split__pane) {
1108
+ height: var(--chats-split-height, 100dvh);
1109
+ min-height: 0;
1110
+ overflow: hidden;
1111
+ }
1112
+ .chats-split:has(.chats-split__pane) > .chats-split__list { display: none; }
1113
+ .chats-split:has(.chats-split__pane) > .chats-split__conversation { flex: 1; }
1114
+ /* None: the page is the list. The empty pane shows under it only to say
1115
+ there are no conversations at all (the inbox's empty state lives there). */
1116
+ .chats-split:not(:has(.chats-split__pane, .chats-split__empty--inbox)) > .chats-split__conversation {
1117
+ display: none;
1118
+ }
1119
+ }
1120
+
1007
1121
  /* ---------------------------------------------------------------------------
1008
1122
  0.5.0 — the messaging dock (chats_dock)
1009
1123
  ---------------------------------------------------------------------------
@@ -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
@@ -7,9 +7,19 @@ module Chats
7
7
  class ConversationsController < ApplicationController
8
8
  # The most rows the dock's recent list shows, whatever `recent:` asks.
9
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"
10
20
 
11
21
  before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
12
- helper_method :chats_counterpart
22
+ helper_method :chats_counterpart, :chats_two_panes?, :chats_inbox_filter
13
23
 
14
24
  # The inbox. Everything is preloaded/batched so rendering N rows costs a
15
25
  # constant number of queries (conversations + last messages + participants
@@ -22,14 +32,7 @@ module Chats
22
32
  # is the dock's own list.
23
33
  return dock if turbo_frame_request_id == "chats_dock_list"
24
34
 
25
- @inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
26
- @rows = @inbox.rows
27
- @unread_counts = @inbox.unread_counts
28
- # Back-compat for inboxes ejected under 0.1.x, which loop over
29
- # @conversations: they keep rendering the flat list (no stacking, i.e.
30
- # exactly what they rendered before). Re-eject or delete your copy to
31
- # get the stacked rows.
32
- @conversations = @inbox.conversations
35
+ load_inbox
33
36
  end
34
37
 
35
38
  # The messaging dock's recent list (`chats_dock`): the first rows of the
@@ -44,6 +47,15 @@ module Chats
44
47
  render :dock, layout: false
45
48
  end
46
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 }
57
+ end
58
+
47
59
  # The thread. Renders the LATEST page of messages; older pages stream in
48
60
  # through a lazy Turbo Frame chain (keyset-paginated — see
49
61
  # Message.before_message and _messages_page.html.erb).
@@ -51,19 +63,19 @@ module Chats
51
63
  # `?window=1` from a Turbo frame is the same thread as a floating dock
52
64
  # window: the window template (its compact header around the same
53
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.
54
72
  def show
55
73
  @participant = @conversation.participant_for(chats_current_messager)
56
74
 
57
75
  anchor = params[:before].present? ? @conversation.messages.find_by(id: params[:before]) : nil
58
- scope = @conversation.messages.includes(:sender, :reactions)
59
- scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
76
+ scope = rendered_messages
60
77
  scope = scope.before_message(anchor) if anchor
61
-
62
- # Fetch newest-first + reverse so "the last N messages" render in
63
- # chronological order. One extra record peeks whether older pages exist.
64
- page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
65
- @more_messages = page.size > Chats.config.messages_per_page
66
- @messages = page.first(Chats.config.messages_per_page).reverse
78
+ @messages, @more_messages = latest_page_of(scope)
67
79
 
68
80
  if anchor
69
81
  # Older-page request from the pagination frame: render just the page.
@@ -71,14 +83,8 @@ module Chats
71
83
  locals: { conversation: @conversation, messages: @messages, more: @more_messages }
72
84
  else
73
85
  # The «new messages» divider: computed BEFORE read! advances the
74
- # horizon (after it, nothing is unread anymore). Anchored to the
75
- # oldest unread bubble on the rendered page; when the backlog runs
76
- # deeper than one page it pins to the top of the page instead —
77
- # the scroll-up frame chain holds the rest.
78
- if @participant&.unread?
79
- @first_unread_id = @participant.unread_messages.where(id: @messages.map(&:id)).oldest_first.pick(:id) ||
80
- @messages.first&.id
81
- end
86
+ # horizon (after it, nothing is unread anymore).
87
+ @first_unread_id = first_unread_id(@participant, @messages)
82
88
 
83
89
  if dock_window?
84
90
  # A window does NOT read on render: it may be restored minimized
@@ -86,51 +92,64 @@ module Chats
86
92
  # swallow the unread badge. The thread controller posts #read once
87
93
  # the window is actually on screen (`read-pending`).
88
94
  render :window, layout: false
89
- elsif !prefetch?
95
+ else
90
96
  # Opening the thread reads it. (Live appends while the thread
91
97
  # stays open are read via the thread controller's POST to #read.)
92
- @participant&.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?
93
103
  end
94
104
  end
95
105
  end
96
106
 
97
- # Stale-thread catch-up: appends messages created — and replaces ones
98
- # edited/tombstoned — since the newest `updated_at` the client has
99
- # rendered (`?since=` in ms). The thread controller calls this when the
100
- # tab wakes from a long sleep or its Turbo Stream subscription
101
- # reconnects, i.e. whenever broadcasts may have been missed. Mobile
102
- # WebViews suspend WebSockets aggressively, so without this a
103
- # backgrounded chat silently loses messages until a manual reload.
104
- # Pattern from Basecamp's Campfire (Rooms::RefreshesController):
105
- # 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.
106
130
  def refresh
107
- head :no_content and return if params[:since].blank?
108
-
109
- since = Time.zone.at(0, params[:since].to_i, :millisecond)
110
- scope = @conversation.messages.includes(:sender, :reactions)
111
- scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
112
-
113
- @new_messages = scope.created_since(since).oldest_first.limit(Chats.config.messages_per_page + 1).to_a
114
-
115
- # A backlog deeper than one page would mean splicing an arbitrary
116
- # amount of history through surgical appends; a Turbo 8 page refresh
117
- # (morph + scroll preservation) re-renders the latest page + frame
118
- # chain correctly instead.
119
- #
120
- # `render turbo_stream:`, NOT `render html: … content_type:` — the
121
- # latter forces text/html and silently ignores the content type, so the
122
- # body says <turbo-stream> while the response says it isn't one.
123
- #
124
- # `request_id: nil` on purpose: Turbo skips a refresh tagged with a
125
- # request id it recognizes as its own, and this response is the answer
126
- # to the client's OWN catch-up fetch — the one client that must not
127
- # skip it.
128
- if @new_messages.size > Chats.config.messages_per_page
129
- render turbo_stream: turbo_stream.refresh(request_id: nil)
130
- return
131
- end
132
-
133
- @updated_messages = scope.updated_since(since)
131
+ head :no_content and return if params[:changed].blank? && params[:since].blank?
132
+
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?
137
+
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?
134
153
  render "chats/conversations/refresh", formats: :turbo_stream
135
154
  end
136
155
 
@@ -213,6 +232,229 @@ module Chats
213
232
  @conversation = find_conversation
214
233
  end
215
234
 
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
245
+ end
246
+
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
+ def render_split_page
252
+ return render :pane, layout: false if pane_request?
253
+
254
+ load_inbox
255
+ render :index
256
+ end
257
+
258
+ def pane_request?
259
+ turbo_frame_request_id == PANE_FRAME
260
+ end
261
+
262
+ # Whether the messaging page is two panes: `config.inbox_layout = :split`
263
+ # (the default), unless this app ejected its views before there were
264
+ # two panes. `rails g chats:views` under 0.5 copied an inbox and a
265
+ # thread page and no conversation pane: that app keeps its own separate
266
+ # pages, rendered and read exactly as before, until it ejects again (or
267
+ # deletes its copies). Views ejected since 0.6 carry the pane, and are
268
+ # two panes like the gem's own.
269
+ def chats_two_panes?
270
+ return @chats_two_panes if defined?(@chats_two_panes)
271
+
272
+ @chats_two_panes = Chats.config.split_inbox? && !views_ejected_before_two_panes?
273
+ end
274
+
275
+ # The list's filter on the two-pane page: its search (`q`) or the stack
276
+ # it shows (`with`). A row opened in the pane carries it into its URL,
277
+ # /messages/:id?with=…, because that URL is what the list's live
278
+ # refreshes re-request: without it, the next refresh would bring back
279
+ # the whole inbox under a list that was filtered. The back link to the
280
+ # list keeps it too.
281
+ def chats_inbox_filter
282
+ { q: params[:q], with: params[:with] }.select { |_, value| value.is_a?(String) && value.present? }
283
+ end
284
+
285
+ def views_ejected_before_two_panes?
286
+ (own_view?("index") || own_view?("show")) && !own_view?("conversation_pane", partial: true)
287
+ end
288
+
289
+ # Whether the app renders its own copy of one of these views, not the gem's.
290
+ def own_view?(name, partial: false)
291
+ template = lookup_context.find_all(name, ["chats/conversations"], partial).first
292
+ template.present? && !template.identifier.to_s.start_with?(Chats::Engine.root.join("app/views").to_s)
293
+ end
294
+
295
+ # Whether showing the conversation reads it, there and then.
296
+ #
297
+ # Never a prefetch (Turbo 8 fetches a link the pointer rests on, which
298
+ # is nobody reading). On the thread page, everything else: opening it
299
+ # reads it.
300
+ #
301
+ # On the two-pane page, the viewer opening it: a row clicked (the
302
+ # pane's frame) or the page loaded by the browser itself (a link from
303
+ # elsewhere, a reload, no JavaScript). NOT a Turbo visit of the page,
304
+ # because one of those is the inbox's own refresh: every live update of
305
+ # the list re-requests this page, maybe in a hidden tab, and a read
306
+ # there would say "Seen" to the sender while nobody looks — and, since
307
+ # a read refreshes the reader's inbox, refresh the page again, forever.
308
+ # Turbo marks each of its requests (X-Turbo-Request-Id) and a refresh
309
+ # looks like any other visit, so a Turbo visit renders the pane unread
310
+ # and its thread reads it once it's on screen. A refresh's pane never
311
+ # gets that far: the page keeps its permanent one, and drops the new.
312
+ def reads_on_render?
313
+ return false if prefetch?
314
+ return true unless chats_two_panes?
315
+
316
+ pane_request? || request.headers["X-Turbo-Request-Id"].blank?
317
+ end
318
+
319
+ def time_from_ms(value)
320
+ Time.zone.at(0, value.to_i, :millisecond)
321
+ end
322
+
323
+ def time_from_us(value)
324
+ Time.zone.at(0, value.to_i, :microsecond)
325
+ end
326
+
327
+ # What to append and what to replace: on the two microsecond cursors,
328
+ # or on the one millisecond cursor of bubbles ejected before them.
329
+ def catch_up_on(scope)
330
+ if params[:changed].present?
331
+ created_at, created_seen = cursor(:after)
332
+ updated_at, updated_seen = cursor(:changed)
333
+ [scope.created_after(created_at, seen: created_seen),
334
+ scope.created_through(created_at, seen: created_seen).updated_after(updated_at, seen: updated_seen)]
335
+ else
336
+ since = time_from_ms(params[:since])
337
+ [scope.created_since(since), scope.updated_since(since)]
338
+ end
339
+ end
340
+
341
+ # The moment and the ids seen with it, from `<name>=µs` and `<name>_ids=1,2`.
342
+ def cursor(name)
343
+ [time_from_us(params[name]), params[:"#{name}_ids"].to_s.split(",")]
344
+ end
345
+
346
+ def cursors_over_limit?
347
+ return true if params[:full].present?
348
+
349
+ %i[after_ids changed_ids].any? do |name|
350
+ ids = params[name].to_s.split(",")
351
+ ids.size > CURSOR_IDS_LIMIT || ids.any? { |id| id.size > CURSOR_ID_LENGTH }
352
+ end
353
+ end
354
+
355
+ # Where the client stands once it has rendered this answer: what it was
356
+ # sent is now known to be on screen, so the cursors move to it. Sent
357
+ # back with the answer (the chats_caught_up action), because only an
358
+ # answer that arrived may move them.
359
+ def caught_up_cursors
360
+ after = cursor(:after).first
361
+ changed_at, changed_seen = cursor(:changed)
362
+ sent = @new_messages + @updated_messages.to_a
363
+ after = [after, *@new_messages.map(&:created_at)].max
364
+
365
+ newest = sent.map(&:updated_at).max
366
+ changed_seen = [] if newest && newest > changed_at
367
+ changed_at = [changed_at, newest].compact.max
368
+ changed_seen |= sent.select { |message| message.updated_at == changed_at }.map { |message| message.id.to_s }
369
+
370
+ cursors_tag(after, changed_at, changed_seen)
371
+ end
372
+
373
+ def cursors_tag(after, changed_at, changed_seen)
374
+ helpers.turbo_stream_action_tag(
375
+ :chats_caught_up,
376
+ target: helpers.dom_id(@conversation, :messages),
377
+ "data-after": microseconds(after), "data-changed": microseconds(changed_at),
378
+ "data-changed-ids": changed_seen.join(","),
379
+ # Which render of the thread asked: an answer for an earlier one is
380
+ # ignored (see the thread controller's caughtUp).
381
+ "data-render": params[:render].to_s.first(CURSOR_ID_LENGTH).presence
382
+ )
383
+ end
384
+
385
+ def microseconds(time)
386
+ (time.to_i * 1_000_000) + time.usec
387
+ end
388
+
389
+ # The history at its latest page, or, for a thread view ejected before
390
+ # the history had its id (no `history=1`), the page refresh it always
391
+ # got. `request_id: nil`: Turbo skips a refresh tagged with a request of
392
+ # its own, and this one answers the client's own catch-up.
393
+ def render_from_scratch
394
+ return render_latest_history if params[:history].present?
395
+
396
+ render turbo_stream: turbo_stream.refresh(request_id: nil)
397
+ end
398
+
399
+ # The conversation's messages with everything a bubble renders.
400
+ def rendered_messages
401
+ scope = @conversation.messages.includes(:sender, :reactions)
402
+ scope.respond_to?(:with_attached_files) ? scope.with_attached_files : scope
403
+ end
404
+
405
+ # The newest page of +scope+, oldest first, and whether older pages
406
+ # exist. Fetched newest-first and reversed; one extra record peeks.
407
+ def latest_page_of(scope)
408
+ page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
409
+ [page.first(Chats.config.messages_per_page).reverse, page.size > Chats.config.messages_per_page]
410
+ end
411
+
412
+ # Where the «new messages» line goes: the oldest unread bubble on the
413
+ # page; when the backlog runs deeper than one page, the top of the page
414
+ # (the scroll-up frame chain holds the rest). Nil when nothing is unread.
415
+ def first_unread_id(participant, messages)
416
+ return unless participant&.unread?
417
+
418
+ participant.unread_messages.where(id: messages.map(&:id)).oldest_first.pick(:id) || messages.first&.id
419
+ end
420
+
421
+ # The thread's history at its latest page, for a catch-up more than a
422
+ # page behind: the same messages, frame chain and «new messages» line
423
+ # the thread opens with (chats/conversations/_history).
424
+ #
425
+ # A complete render, so it carries its own cursors: the newest creation
426
+ # and the newest change on the page.
427
+ def render_latest_history
428
+ messages, more = latest_page_of(rendered_messages)
429
+ participant = @conversation.participant_for(chats_current_messager)
430
+ newest_change = messages.map(&:updated_at).max || Time.zone.at(0)
431
+ history = turbo_stream.update(
432
+ helpers.dom_id(@conversation, :history),
433
+ partial: "chats/conversations/history",
434
+ locals: { conversation: @conversation, messages: messages, more: more,
435
+ first_unread_id: first_unread_id(participant, messages),
436
+ counterpart: chats_counterpart, window: false }
437
+ )
438
+ cursors = cursors_tag(messages.map(&:created_at).max || Time.zone.at(0), newest_change,
439
+ messages.select { |message| message.updated_at == newest_change }.map { |m| m.id.to_s })
440
+
441
+ render turbo_stream: helpers.safe_join([history, cursors])
442
+ end
443
+
444
+ # Under `deletion: :hard` a deleted message leaves nothing to replace:
445
+ # the catch-up names every message from the client's oldest bubble on
446
+ # that still exists, and the client removes the bubbles not named.
447
+ # `until` sits PRESENT_MARGIN before the list is read: a message whose
448
+ # insert had not committed yet is missing from the list but may already
449
+ # have reached the client live — it must not be taken for a deleted one.
450
+ def present_messages
451
+ margin = Time.current - PRESENT_MARGIN
452
+ ids = @conversation.messages.where(created_at: time_from_us(params[:from])..).pluck(:id)
453
+
454
+ { ids: ids.map { |id| helpers.dom_id(Chats::Message.new(id: id)) },
455
+ until: (margin.to_i * 1_000_000) + margin.usec }
456
+ end
457
+
216
458
  # A dock window asks for its thread from inside its own turbo frame. A
217
459
  # plain visit to the same URL (no frame) still gets the page.
218
460
  def dock_window?
@@ -57,13 +57,23 @@ module Chats
57
57
  # tab docks). Subscribes to the messager's badge stream so it updates in
58
58
  # real time without refreshing the page it sits on (see
59
59
  # Chats::Broadcasts for why badges get their own stream).
60
+ #
61
+ # The viewer's own badge also catches up on updates its stream missed
62
+ # (the chats--unread-badge controller asks GET /badge on a reconnect, on
63
+ # a return to a hidden page, on a restored page). Its wrapper is
64
+ # `display: contents` in chats.css, so it changes no layout.
60
65
  def chats_unread_badge(messager = chats_viewer)
61
66
  return if messager.nil?
62
67
 
63
- safe_join([
64
- turbo_stream_from(messager, :chats_badge),
65
- render(partial: "chats/shared/unread_badge", locals: { count: messager.unread_chats_count })
66
- ])
68
+ badge = safe_join([
69
+ turbo_stream_from(messager, :chats_badge),
70
+ render(partial: "chats/shared/unread_badge", locals: { count: messager.unread_chats_count })
71
+ ])
72
+ return badge unless messager == chats_viewer
73
+
74
+ tag.span(badge, class: "chats-unread-badge-live",
75
+ data: { controller: "chats--unread-badge",
76
+ "chats--unread-badge-url-value": chats_routes.badge_conversations_path })
67
77
  end
68
78
 
69
79
  # The messaging dock, LinkedIn / Steam style: a bar in the bottom-right
@@ -117,6 +127,19 @@ module Chats
117
127
  { chats_dock_toggle: true }
118
128
  end
119
129
 
130
+ # The CLDR plural categories JavaScript's Intl.PluralRules answers with.
131
+ PLURAL_CATEGORIES = %i[zero one two few many other].freeze
132
+
133
+ # Every plural form of +key+ the current locale provides, for client-side
134
+ # pluralization ({ one: "…", few: "…", other: "…" }). The gem ships
135
+ # one/other; a host adds the forms its languages need (Polish few/many,
136
+ # Arabic zero/two…) in its own locale files. Empty when the key is
137
+ # missing, never a "translation missing" string.
138
+ def chats_plural_forms(key)
139
+ forms = I18n.t(key, default: {})
140
+ forms.is_a?(Hash) ? forms.slice(*PLURAL_CATEGORIES) : {}
141
+ end
142
+
120
143
  # Whether +viewer+ muted +conversation+, read from the preloaded seats
121
144
  # (the inbox loads them), so a row costs no query for it.
122
145
  def chats_muted?(conversation, viewer)