chats 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,13 @@ 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
10
17
 
11
18
  before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
12
19
  helper_method :chats_counterpart
@@ -44,6 +51,15 @@ module Chats
44
51
  render :dock, layout: false
45
52
  end
46
53
 
54
+ # The viewer's unread badges as a Turbo Stream: the nav badge
55
+ # (`chats_unread_badge`) and every badge in the dock, replaced with the
56
+ # same count the badge streams send. What a page asks for when it may
57
+ # have missed them (chats/recovery: a reconnect, a return to a hidden
58
+ # page, a restored page). Reads nothing, writes nothing.
59
+ def badge
60
+ render :badge, formats: :turbo_stream, locals: { count: chats_current_messager.unread_chats_count }
61
+ end
62
+
47
63
  # The thread. Renders the LATEST page of messages; older pages stream in
48
64
  # through a lazy Turbo Frame chain (keyset-paginated — see
49
65
  # Message.before_message and _messages_page.html.erb).
@@ -55,15 +71,9 @@ module Chats
55
71
  @participant = @conversation.participant_for(chats_current_messager)
56
72
 
57
73
  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)
74
+ scope = rendered_messages
60
75
  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
76
+ @messages, @more_messages = latest_page_of(scope)
67
77
 
68
78
  if anchor
69
79
  # Older-page request from the pagination frame: render just the page.
@@ -71,14 +81,8 @@ module Chats
71
81
  locals: { conversation: @conversation, messages: @messages, more: @more_messages }
72
82
  else
73
83
  # 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
84
+ # horizon (after it, nothing is unread anymore).
85
+ @first_unread_id = first_unread_id(@participant, @messages)
82
86
 
83
87
  if dock_window?
84
88
  # A window does NOT read on render: it may be restored minimized
@@ -94,43 +98,52 @@ module Chats
94
98
  end
95
99
  end
96
100
 
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
101
+ # Stale-thread catch-up, on the client's cursors (see Message's catch-up
102
+ # scopes): `after=` µs and `after_ids=`, its newest creation and the ids
103
+ # it has at that moment (0 and none for an empty thread); `changed=` µs
104
+ # and `changed_ids=`, its newest change; `from=` µs, its oldest bubble's
105
+ # creation. Bubbles ejected before they carried these send `since=` ms
106
+ # alone, and are answered as they always were. The thread
107
+ # controller calls this whenever chats/recovery says the stream may have
108
+ # missed something: a reconnect, a return after 10 seconds hidden, the
109
+ # network back, a restored page. Mobile WebViews suspend WebSockets
110
+ # aggressively, so without this a backgrounded chat silently loses
111
+ # messages until a manual reload. Pattern from Basecamp's Campfire
112
+ # (Rooms::RefreshesController): https://github.com/basecamp/once-campfire
113
+ #
114
+ # The answer touches the history only, never the composer or its draft:
115
+ # messages created since are appended, ones changed since (edits,
116
+ # tombstones, reactions) replaced, the read state replaced, and — under
117
+ # `deletion: :hard` — the bubbles that no longer exist removed. Reads
118
+ # nothing: catching up is not reading (the controller posts #read once
119
+ # the viewer can see the new messages).
120
+ #
121
+ # `render turbo_stream:`, NOT `render html: … content_type:` — the
122
+ # latter forces text/html and silently ignores the content type, so the
123
+ # body says <turbo-stream> while the response says it isn't one.
106
124
  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)
125
+ head :no_content and return if params[:changed].blank? && params[:since].blank?
126
+
127
+ # A client whose cursors name more than the limit (a busy thread whose
128
+ # live bubbles piled up since it last caught up, or a forged request)
129
+ # is answered with what is cheap at any size: the latest page.
130
+ return render_from_scratch if cursors_over_limit?
131
+
132
+ scope = rendered_messages
133
+ new_messages, @updated_messages = catch_up_on(scope)
134
+ @new_messages = new_messages.oldest_first.limit(Chats.config.messages_per_page + 1).to_a
135
+
136
+ # More than a page behind: splicing an arbitrary backlog through
137
+ # surgical appends would leave a hole in the scroll-up chain. Re-render
138
+ # the history at the latest page instead, exactly as opening the thread
139
+ # does, frame chain included. A thread view ejected before the history
140
+ # had its id (no `history=1`) gets the page refresh it always got;
141
+ # `request_id: nil`, because Turbo skips a refresh tagged with a request
142
+ # of its own, and this one answers the client's own catch-up.
143
+ return render_from_scratch if @new_messages.size > Chats.config.messages_per_page
144
+
145
+ @caught_up = caught_up_cursors if params[:changed].present?
146
+ @present = present_messages if Chats.config.deletion == :hard && params[:from].present?
134
147
  render "chats/conversations/refresh", formats: :turbo_stream
135
148
  end
136
149
 
@@ -213,6 +226,145 @@ module Chats
213
226
  @conversation = find_conversation
214
227
  end
215
228
 
229
+ def time_from_ms(value)
230
+ Time.zone.at(0, value.to_i, :millisecond)
231
+ end
232
+
233
+ def time_from_us(value)
234
+ Time.zone.at(0, value.to_i, :microsecond)
235
+ end
236
+
237
+ # What to append and what to replace: on the two microsecond cursors,
238
+ # or on the one millisecond cursor of bubbles ejected before them.
239
+ def catch_up_on(scope)
240
+ if params[:changed].present?
241
+ created_at, created_seen = cursor(:after)
242
+ updated_at, updated_seen = cursor(:changed)
243
+ [scope.created_after(created_at, seen: created_seen),
244
+ scope.created_through(created_at, seen: created_seen).updated_after(updated_at, seen: updated_seen)]
245
+ else
246
+ since = time_from_ms(params[:since])
247
+ [scope.created_since(since), scope.updated_since(since)]
248
+ end
249
+ end
250
+
251
+ # The moment and the ids seen with it, from `<name>=µs` and `<name>_ids=1,2`.
252
+ def cursor(name)
253
+ [time_from_us(params[name]), params[:"#{name}_ids"].to_s.split(",")]
254
+ end
255
+
256
+ def cursors_over_limit?
257
+ return true if params[:full].present?
258
+
259
+ %i[after_ids changed_ids].any? do |name|
260
+ ids = params[name].to_s.split(",")
261
+ ids.size > CURSOR_IDS_LIMIT || ids.any? { |id| id.size > CURSOR_ID_LENGTH }
262
+ end
263
+ end
264
+
265
+ # Where the client stands once it has rendered this answer: what it was
266
+ # sent is now known to be on screen, so the cursors move to it. Sent
267
+ # back with the answer (the chats_caught_up action), because only an
268
+ # answer that arrived may move them.
269
+ def caught_up_cursors
270
+ after = cursor(:after).first
271
+ changed_at, changed_seen = cursor(:changed)
272
+ sent = @new_messages + @updated_messages.to_a
273
+ after = [after, *@new_messages.map(&:created_at)].max
274
+
275
+ newest = sent.map(&:updated_at).max
276
+ changed_seen = [] if newest && newest > changed_at
277
+ changed_at = [changed_at, newest].compact.max
278
+ changed_seen |= sent.select { |message| message.updated_at == changed_at }.map { |message| message.id.to_s }
279
+
280
+ cursors_tag(after, changed_at, changed_seen)
281
+ end
282
+
283
+ def cursors_tag(after, changed_at, changed_seen)
284
+ helpers.turbo_stream_action_tag(
285
+ :chats_caught_up,
286
+ target: helpers.dom_id(@conversation, :messages),
287
+ "data-after": microseconds(after), "data-changed": microseconds(changed_at),
288
+ "data-changed-ids": changed_seen.join(","),
289
+ # Which render of the thread asked: an answer for an earlier one is
290
+ # ignored (see the thread controller's caughtUp).
291
+ "data-render": params[:render].to_s.first(CURSOR_ID_LENGTH).presence
292
+ )
293
+ end
294
+
295
+ def microseconds(time)
296
+ (time.to_i * 1_000_000) + time.usec
297
+ end
298
+
299
+ # The history at its latest page, or, for a thread view ejected before
300
+ # the history had its id (no `history=1`), the page refresh it always
301
+ # got. `request_id: nil`: Turbo skips a refresh tagged with a request of
302
+ # its own, and this one answers the client's own catch-up.
303
+ def render_from_scratch
304
+ return render_latest_history if params[:history].present?
305
+
306
+ render turbo_stream: turbo_stream.refresh(request_id: nil)
307
+ end
308
+
309
+ # The conversation's messages with everything a bubble renders.
310
+ def rendered_messages
311
+ scope = @conversation.messages.includes(:sender, :reactions)
312
+ scope.respond_to?(:with_attached_files) ? scope.with_attached_files : scope
313
+ end
314
+
315
+ # The newest page of +scope+, oldest first, and whether older pages
316
+ # exist. Fetched newest-first and reversed; one extra record peeks.
317
+ def latest_page_of(scope)
318
+ page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
319
+ [page.first(Chats.config.messages_per_page).reverse, page.size > Chats.config.messages_per_page]
320
+ end
321
+
322
+ # Where the «new messages» line goes: the oldest unread bubble on the
323
+ # page; when the backlog runs deeper than one page, the top of the page
324
+ # (the scroll-up frame chain holds the rest). Nil when nothing is unread.
325
+ def first_unread_id(participant, messages)
326
+ return unless participant&.unread?
327
+
328
+ participant.unread_messages.where(id: messages.map(&:id)).oldest_first.pick(:id) || messages.first&.id
329
+ end
330
+
331
+ # The thread's history at its latest page, for a catch-up more than a
332
+ # page behind: the same messages, frame chain and «new messages» line
333
+ # the thread opens with (chats/conversations/_history).
334
+ #
335
+ # A complete render, so it carries its own cursors: the newest creation
336
+ # and the newest change on the page.
337
+ def render_latest_history
338
+ messages, more = latest_page_of(rendered_messages)
339
+ participant = @conversation.participant_for(chats_current_messager)
340
+ newest_change = messages.map(&:updated_at).max || Time.zone.at(0)
341
+ history = turbo_stream.update(
342
+ helpers.dom_id(@conversation, :history),
343
+ partial: "chats/conversations/history",
344
+ locals: { conversation: @conversation, messages: messages, more: more,
345
+ first_unread_id: first_unread_id(participant, messages),
346
+ counterpart: chats_counterpart, window: false }
347
+ )
348
+ cursors = cursors_tag(messages.map(&:created_at).max || Time.zone.at(0), newest_change,
349
+ messages.select { |message| message.updated_at == newest_change }.map { |m| m.id.to_s })
350
+
351
+ render turbo_stream: helpers.safe_join([history, cursors])
352
+ end
353
+
354
+ # Under `deletion: :hard` a deleted message leaves nothing to replace:
355
+ # the catch-up names every message from the client's oldest bubble on
356
+ # that still exists, and the client removes the bubbles not named.
357
+ # `until` sits PRESENT_MARGIN before the list is read: a message whose
358
+ # insert had not committed yet is missing from the list but may already
359
+ # have reached the client live — it must not be taken for a deleted one.
360
+ def present_messages
361
+ margin = Time.current - PRESENT_MARGIN
362
+ ids = @conversation.messages.where(created_at: time_from_us(params[:from])..).pluck(:id)
363
+
364
+ { ids: ids.map { |id| helpers.dom_id(Chats::Message.new(id: id)) },
365
+ until: (margin.to_i * 1_000_000) + margin.usec }
366
+ end
367
+
216
368
  # A dock window asks for its thread from inside its own turbo frame. A
217
369
  # plain visit to the same URL (no frame) still gets the page.
218
370
  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)
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
2
3
 
3
4
  // chats--dock: the messaging dock (`chats_dock`) — LinkedIn / Steam style
4
5
  // floating conversation windows in the bottom-right corner.
@@ -30,6 +31,13 @@ import { Controller } from "@hotwired/stimulus"
30
31
  // its window pops open (in dock mode only, never stealing
31
32
  // focus, never for one closed in the last five minutes,
32
33
  // never over a window in use) and is announced politely.
34
+ // * catching up what the dock's stream missed (chats/recovery: a
35
+ // reconnect, a return after 10 seconds hidden, the
36
+ // network back, a restored page) reloads the recent list,
37
+ // morphed in place so its scroll and search stay, and
38
+ // fetches the badges again. Each open window's thread
39
+ // catches up by itself. A missed message never pops a
40
+ // window open late: the badge and the list say it.
33
41
  // * memory per viewer in localStorage (`chats:dock:v1:<key>`):
34
42
  // which windows, minimized or not, recently closed ones,
35
43
  // and the panel; drafts under `chats:draft:<key>:<id>`.
@@ -75,7 +83,8 @@ export default class extends Controller {
75
83
  // isolated engine it is "conversation", not "chats_conversation".
76
84
  recordKey: { type: String, default: "conversation" },
77
85
  announcement: String,
78
- windowUnread: String
86
+ windowUnread: String,
87
+ badgeUrl: String
79
88
  }
80
89
 
81
90
  // Stimulus runs the target callbacks (windowTargetConnected…) BEFORE
@@ -103,6 +112,10 @@ export default class extends Controller {
103
112
  this.layout()
104
113
  this.restore()
105
114
  this.pageChanged()
115
+
116
+ // Its own stream only: each window's thread watches its own.
117
+ this.stopRecovery = recoverMissedBroadcasts(this.element, () => this.catchUp(),
118
+ { sources: ":scope > turbo-cable-stream-source" })
106
119
  }
107
120
 
108
121
  disconnect() {
@@ -111,6 +124,7 @@ export default class extends Controller {
111
124
  window.visualViewport?.removeEventListener("resize", this.fitViewport)
112
125
  window.visualViewport?.removeEventListener("scroll", this.fitViewport)
113
126
  this.releaseSheet()
127
+ this.stopRecovery?.()
114
128
  }
115
129
 
116
130
  // --- Opening ---------------------------------------------------------------
@@ -459,6 +473,11 @@ export default class extends Controller {
459
473
  })
460
474
  }
461
475
 
476
+ catchUp() {
477
+ this.refreshList()
478
+ if (this.badgeUrlValue) renderStreamFrom(this.badgeUrlValue)
479
+ }
480
+
462
481
  badgeTargetConnected(badge) {
463
482
  const count = Number(badge.dataset.count || 0)
464
483
  if (this.lastCount !== undefined && count > this.lastCount) this.flash(badge, "chats-badge--bumped", 200)
@@ -1049,13 +1068,30 @@ export default class extends Controller {
1049
1068
  return content
1050
1069
  }
1051
1070
 
1071
+ // "3 new messages", in the plural form the page's language uses for 3:
1072
+ // Polish says 2, 3, 4, 22 differently from 5 and 1; Russian says 21 like 1.
1073
+ // The dock renders every form its locale has (chats.dock.window_unread:
1074
+ // zero, one, two, few, many, other); a form the locale lacks reads as
1075
+ // `other`.
1052
1076
  unreadLabel(count) {
1077
+ let forms
1053
1078
  try {
1054
- const forms = JSON.parse(this.windowUnreadValue || "{}")
1055
- return (count === 1 ? forms.one : forms.other)?.replace("%{count}", count) || ""
1079
+ forms = JSON.parse(this.windowUnreadValue || "{}")
1056
1080
  } catch {
1057
1081
  return ""
1058
1082
  }
1083
+ return (forms[this.pluralCategory(count)] ?? forms.other)?.replace("%{count}", count) || ""
1084
+ }
1085
+
1086
+ // CLDR's category for +count+ in the page's language (<html lang>, else
1087
+ // the browser's). Without Intl, or with a language tag it can't read, the
1088
+ // old two-form rule.
1089
+ pluralCategory(count) {
1090
+ try {
1091
+ return new Intl.PluralRules(document.documentElement.lang || navigator.language || undefined).select(count)
1092
+ } catch {
1093
+ return count === 1 ? "one" : "other"
1094
+ }
1059
1095
  }
1060
1096
 
1061
1097
  pixels(property, fallback) {