chats 0.4.2 → 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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +478 -0
  3. data/README.md +150 -10
  4. data/app/assets/stylesheets/chats.css +599 -0
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +273 -55
  7. data/app/helpers/chats/engine_helper.rb +102 -6
  8. data/app/javascript/chats/composer_controller.js +14 -1
  9. data/app/javascript/chats/dock_controller.js +1106 -0
  10. data/app/javascript/chats/recovery.js +539 -0
  11. data/app/javascript/chats/refresh_inbox_controller.js +55 -65
  12. data/app/javascript/chats/thread_controller.js +317 -60
  13. data/app/javascript/chats/unread_badge_controller.js +24 -0
  14. data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
  15. data/app/views/chats/conversations/_group.html.erb +6 -2
  16. data/app/views/chats/conversations/_history.html.erb +54 -0
  17. data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
  18. data/app/views/chats/conversations/_thread.html.erb +133 -0
  19. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  20. data/app/views/chats/conversations/dock.html.erb +40 -0
  21. data/app/views/chats/conversations/index.html.erb +24 -10
  22. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  23. data/app/views/chats/conversations/show.html.erb +11 -118
  24. data/app/views/chats/conversations/window.html.erb +26 -0
  25. data/app/views/chats/dock/_badge.html.erb +12 -0
  26. data/app/views/chats/dock/_dock.html.erb +138 -0
  27. data/app/views/chats/dock/_signal.html.erb +26 -0
  28. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  29. data/app/views/chats/dock/_window.html.erb +23 -0
  30. data/app/views/chats/dock/_window_header.html.erb +75 -0
  31. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  32. data/app/views/chats/messages/_composer.html.erb +11 -1
  33. data/app/views/chats/messages/_message.html.erb +8 -6
  34. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  35. data/config/importmap.rb +7 -0
  36. data/config/locales/en.yml +17 -0
  37. data/config/locales/es.yml +17 -0
  38. data/config/routes.rb +8 -0
  39. data/docs/screenshots/dock-bar.png +0 -0
  40. data/docs/screenshots/dock-desktop.png +0 -0
  41. data/docs/screenshots/dock-phone-button.png +0 -0
  42. data/docs/screenshots/dock-phone-panel.png +0 -0
  43. data/docs/screenshots/dock-phone-window.png +0 -0
  44. data/gemfiles/rails_7.1.gemfile +2 -0
  45. data/gemfiles/rails_7.2.gemfile +2 -0
  46. data/gemfiles/rails_8.1.gemfile +2 -0
  47. data/lib/chats/broadcasts.rb +178 -17
  48. data/lib/chats/inbox.rb +68 -30
  49. data/lib/chats/inbox_refresh_job.rb +14 -0
  50. data/lib/chats/models/concerns/messager.rb +10 -2
  51. data/lib/chats/models/conversation.rb +57 -0
  52. data/lib/chats/models/message.rb +98 -9
  53. data/lib/chats/models/reaction.rb +13 -0
  54. data/lib/chats/version.rb +1 -1
  55. data/lib/generators/chats/templates/initializer.rb +1 -0
  56. data/lib/generators/chats/views_generator.rb +3 -2
  57. metadata +23 -2
@@ -1,86 +1,76 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { recoverMissedBroadcasts } from "chats/recovery"
2
3
 
3
- // chats--refresh-inbox — heals the inbox after a MISSED broadcast.
4
+ // chats--refresh-inbox — the inbox is never left showing an old list.
4
5
  //
5
- // The inbox updates via Turbo 8 page-refresh broadcasts
6
- // (Chats::Broadcasts.refresh_inbox_of → broadcast_refresh_later_to). Action
7
- // Cable has no replay: a refresh broadcast sent while this client's socket
8
- // was down (backgrounded tab/app, network blip, laptop asleep) is gone
9
- // forever, and the inbox silently sits at its last render until the user
10
- // happens to navigate. This controller re-runs the SAME page refresh on the
11
- // two moments we might have missed one — the socket reconnecting, and the
12
- // tab becoming visible again after long enough that the socket was reaped.
6
+ // The inbox updates through Turbo 8 page refreshes on the viewer's inbox
7
+ // stream (Chats::Broadcasts.refresh_inbox_of): the page re-requests itself
8
+ // and morphs, scroll kept. This controller wraps that stream's
9
+ // <turbo-cable-stream-source> and covers the ways a refresh can be lost:
13
10
  //
14
- // It deliberately reuses chats--thread's reconnect/visibility detection (the
15
- // gem-native approach: observe the <turbo-cable-stream-source> `connected`
16
- // attribute rather than stand up a dedicated HeartbeatChannel — the attribute
17
- // IS the heartbeat). The difference is the recovery action: the thread does an
18
- // HTTP `?since=` delta because it patches messages surgically, whereas the
19
- // inbox already broadcasts whole-page refreshes, so the catch-up is just
20
- // `Turbo.session.refresh()` — idempotent, morphing, scroll-preserving.
11
+ // * MISSED, because the socket was down when it was sent: chats/recovery
12
+ // runs the same page refresh when the stream reconnects, when the page
13
+ // comes back after 10 seconds hidden, when the network comes back, and
14
+ // when the browser restores the page from its back/forward cache.
15
+ // * DROPPED, because it arrived while a visit was in flight: Turbo ignores
16
+ // a refresh while another visit is running (Session#refresh checks
17
+ // `navigator.currentVisit`), so a message that lands while the previous
18
+ // refresh is being fetched would be lost with a healthy socket. One
19
+ // arriving then is run once that visit completes.
21
20
  //
22
- // Registered automatically under the identifier "chats--refresh-inbox" via
23
- // the engine's importmap pin (see config/importmap.rb + Chats::Engine).
21
+ // A refresh never rewrites the field the viewer is typing in: the morph
22
+ // leaves the focused field's value alone. The search itself survives a
23
+ // refresh because the inbox's search form keeps the query in the URL
24
+ // (`data-turbo-action="replace"`), which is what a refresh re-requests.
24
25
  //
25
- // Doctrine: Turbo Stream delivery is a best-effort enhancement; the page must
26
- // be correct without it. https://turbo.hotwired.dev/handbook/streams
27
- const REFRESH_AFTER_HIDDEN_MS = 60_000
28
-
26
+ // The catch-up IS a page refresh, on the same code path a broadcast takes:
27
+ // in Hotwire Native, Turbo hands it to the native adapter like any other.
28
+ // Registered as "chats--refresh-inbox" through the engine's importmap pin.
29
29
  export default class extends Controller {
30
30
  connect() {
31
- this.streamDropped = false
32
- this.hiddenAt = null
33
- document.addEventListener("visibilitychange", this.visibilityChanged)
34
- this.watchStreamSource()
31
+ this.stopRecovery = recoverMissedBroadcasts(this.element, () => this.refresh())
32
+ document.addEventListener("turbo:visit", this.visitStarted)
33
+ document.addEventListener("turbo:load", this.visitCompleted)
34
+ document.addEventListener("turbo:before-stream-render", this.streamArrived)
35
+ document.addEventListener("turbo:before-morph-attribute", this.keepWhatIsBeingTyped)
35
36
  }
36
37
 
37
38
  disconnect() {
38
- document.removeEventListener("visibilitychange", this.visibilityChanged)
39
- this.sourceObserver?.disconnect()
39
+ this.stopRecovery()
40
+ document.removeEventListener("turbo:visit", this.visitStarted)
41
+ document.removeEventListener("turbo:load", this.visitCompleted)
42
+ document.removeEventListener("turbo:before-stream-render", this.streamArrived)
43
+ document.removeEventListener("turbo:before-morph-attribute", this.keepWhatIsBeingTyped)
40
44
  }
41
45
 
42
- visibilityChanged = () => {
43
- if (document.visibilityState === "visible") {
44
- // Hidden long enough that the socket may have been reaped → catch up
45
- // on whatever the missed broadcasts would have refreshed.
46
- if (this.hiddenAt && Date.now() - this.hiddenAt > REFRESH_AFTER_HIDDEN_MS) {
47
- this.refresh()
48
- }
49
- this.hiddenAt = null
50
- } else {
51
- this.hiddenAt = Date.now()
52
- }
46
+ refresh() {
47
+ window.Turbo?.session?.refresh(document.baseURI, { method: "morph", scroll: "preserve" })
53
48
  }
54
49
 
55
- // Reconnect detection without a dedicated cable channel: turbo-rails toggles
56
- // a `connected` attribute on <turbo-cable-stream-source> as the Action Cable
57
- // subscription confirms/drops, so observing it IS the heartbeat. A refresh
58
- // that morphs the page can momentarily strip the attribute — harmless here:
59
- // it only arms `streamDropped`, and the next genuine reconnect does one
60
- // (cheap, idempotent) catch-up refresh.
61
- watchStreamSource() {
62
- const source = this.element.querySelector("turbo-cable-stream-source")
63
- if (!source || typeof MutationObserver === "undefined") return
50
+ // --- A refresh that lands mid-visit -------------------------------------------
51
+ //
52
+ // Turbo marks <html aria-busy> for as long as a visit runs.
64
53
 
65
- this.sourceObserver = new MutationObserver(() => {
66
- const connected = source.hasAttribute("connected")
67
- if (connected && this.streamDropped) {
68
- this.streamDropped = false
69
- this.refresh()
70
- } else if (!connected) {
71
- this.streamDropped = true
72
- }
73
- })
74
- this.sourceObserver.observe(source, { attributes: true, attributeFilter: ["connected"] })
54
+ streamArrived = (event) => {
55
+ if (event.target.getAttribute?.("action") !== "refresh") return
56
+ if (document.documentElement.hasAttribute("aria-busy")) this.refreshWaiting = true
75
57
  }
76
58
 
77
- refresh() {
78
- if (document.visibilityState !== "visible") return
79
- // Don't morph the list out from under someone typing in the search box —
80
- // the next trigger (or their own submit) will catch it up.
81
- const active = document.activeElement
82
- if (active && active.matches("input, textarea, select")) return
59
+ // A visit that starts after the refresh arrived fetches what it was about.
60
+ visitStarted = () => {
61
+ this.refreshWaiting = false
62
+ }
63
+
64
+ visitCompleted = () => {
65
+ if (!this.refreshWaiting) return
66
+
67
+ this.refreshWaiting = false
68
+ if (this.element.isConnected) this.refresh()
69
+ }
70
+
71
+ // --- Typing through a refresh ---------------------------------------------------
83
72
 
84
- window.Turbo?.session?.refresh(document.baseURI)
73
+ keepWhatIsBeingTyped = (event) => {
74
+ if (event.detail?.attributeName === "value" && event.target === document.activeElement) event.preventDefault()
85
75
  }
86
76
  }
@@ -1,15 +1,18 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { afterRendering, cancelStreamsFor, recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
2
3
 
3
- // Stale-thread refresh + DOM budget (both patterns from Basecamp's
4
+ // Stale-thread catch-up + DOM budget (both patterns from Basecamp's
4
5
  // Campfire, https://github.com/basecamp/once-campfire — its
5
6
  // refresh_room_controller and message_paginator):
6
- // * refresh when the tab was hidden long enough that the WebSocket was
7
- // probably reaped (mobile WebViews suspend sockets aggressively), and
8
- // whenever the Turbo Stream subscription reconnects after a drop;
7
+ // * catch up on what the stream missed whenever chats/recovery says it
8
+ // may have (a reconnect, a return after 10 seconds hidden, the network
9
+ // back, a page restored from the back/forward cache or a Turbo
10
+ // snapshot): GET #refresh?since= appends, replaces and removes exactly
11
+ // what changed, so the composer, its draft and the scroll position are
12
+ // never touched;
9
13
  // * cap rendered bubbles so day-long sessions in a busy group don't
10
14
  // grow the DOM unboundedly (trimmed history stays reachable — the
11
15
  // pagination anchor is rebuilt to re-fetch it on scroll-up).
12
- const REFRESH_AFTER_HIDDEN_MS = 60_000
13
16
  // Long-press tuning: Telegram-ish. The move tolerance keeps a scroll
14
17
  // gesture from ever reading as a press.
15
18
  const LONG_PRESS_MS = 450
@@ -19,6 +22,11 @@ const PRESS_MOVE_TOLERANCE_PX = 12
19
22
  // last few pixels home. Keep this a hair ABOVE the CSS transform duration.
20
23
  const POPUP_TRANSITION_MS = 280
21
24
  const MAX_RENDERED_MESSAGES = 300
25
+ // Keep in step with ConversationsController::CURSOR_IDS_LIMIT.
26
+ const CURSOR_IDS = 200
27
+
28
+ // A name for one render of a thread.
29
+ const newRender = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
22
30
  const TRIM_LEEWAY = 20
23
31
 
24
32
  // chats--thread: everything live about an open conversation.
@@ -36,9 +44,17 @@ const TRIM_LEEWAY = 20
36
44
  // already near the bottom (never yank them up
37
45
  // mid-history-read)
38
46
  // * mark-as-read debounce a POST to #read when foreign messages
39
- // arrive while the tab is visible — that POST
40
- // advances our read horizon and broadcasts fresh
41
- // read-state to everyone
47
+ // arrive while the thread is on screen — that
48
+ // POST advances our read horizon and broadcasts
49
+ // fresh read-state to everyone. Only while the
50
+ // viewer can actually be reading: a visible tab,
51
+ // a thread with room to show itself (a minimized
52
+ // dock window has none), and — inside a dock
53
+ // window — one the viewer is engaged with (the
54
+ // dock's `data-chats-active` gate; a window that
55
+ // popped open by itself waits for a focus, a
56
+ // press or a scroll). A dock window is rendered
57
+ // unread (`readPending`) and reads the same way.
42
58
  // * sent / seen receipts derive per-message ticks client-side from the
43
59
  // broadcast read-state payload
44
60
  // * day separators recalculate local-calendar markers after the
@@ -74,11 +90,20 @@ export default class extends Controller {
74
90
  daySeparatorClass: { type: String, default: "chats-day-separator" },
75
91
  typingSuffix: String,
76
92
  copiedLabel: String,
77
- group: Boolean
93
+ group: Boolean,
94
+ readPending: Boolean
78
95
  }
79
96
 
80
97
  connect() {
81
98
  this.registerTypingStreamAction()
99
+ this.registerPruneStreamAction()
100
+ this.registerCaughtUpStreamAction()
101
+ this.element.dataset.chatsRender ||= newRender()
102
+ this.caughtUp({ detail: this.storedCursors() || this.cursorsOfRender() })
103
+ // The dock's engagement gate: the window frame around a dock thread.
104
+ // No gate (the thread page) means always engaged.
105
+ this.gate = this.element.closest("[data-chats-active]")
106
+ this.gate?.addEventListener("chats:activated", this.flushPendingRead)
82
107
 
83
108
  // targetConnected fires for every server-rendered bubble right after
84
109
  // connect; this flag keeps the initial flood from triggering N scrolls
@@ -90,6 +115,7 @@ export default class extends Controller {
90
115
  this.renderReceipts()
91
116
  this.renderDaySeparators()
92
117
  this.renderMessageGroups()
118
+ if (this.readPendingValue) this.queueRead()
93
119
  })
94
120
 
95
121
  if (this.hasTypingTarget) {
@@ -110,7 +136,10 @@ export default class extends Controller {
110
136
  event.stopPropagation()
111
137
  }
112
138
  this.element.addEventListener("click", this.suppressClickCapture, true)
113
- this.watchStreamSource()
139
+ this.element.addEventListener("chats:prune", this.prune)
140
+ this.element.addEventListener("chats:caught-up", this.caughtUp)
141
+ this.stopRecovery = recoverMissedBroadcasts(this.element, () => this.refreshThread(), { stale: () => this.stale })
142
+ this.watchScreenPresence()
114
143
  }
115
144
 
116
145
  disconnect() {
@@ -119,7 +148,19 @@ export default class extends Controller {
119
148
  }
120
149
  document.removeEventListener("visibilitychange", this.visibilityChanged)
121
150
  clearTimeout(this.readTimer)
122
- this.sourceObserver?.disconnect()
151
+ this.stopRecovery?.()
152
+ // What this render asked for is not wanted any more — once it is really
153
+ // gone. A Turbo visit carries the dock from page to page by taking it
154
+ // off the page and putting it back once the new body is in: a window's
155
+ // thread is away for the length of that render and keeps everything,
156
+ // its answer on the way included. Still off the page once the render is
157
+ // over is a thread that went away (another page, a window reloaded or
158
+ // closed).
159
+ afterRendering().then(() => { if (!this.element.isConnected) cancelStreamsFor(this.element) })
160
+ this.element.removeEventListener("chats:prune", this.prune)
161
+ this.element.removeEventListener("chats:caught-up", this.caughtUp)
162
+ this.presenceObserver?.disconnect()
163
+ this.gate?.removeEventListener("chats:activated", this.flushPendingRead)
123
164
  this.element.removeEventListener("click", this.suppressClickCapture, true)
124
165
  clearTimeout(this.pressTimer)
125
166
  this.teardownPopup()
@@ -132,7 +173,7 @@ export default class extends Controller {
132
173
  // --- Bubbles ---------------------------------------------------------------
133
174
 
134
175
  messageTargetConnected(element) {
135
- this.trackLoadCursor(element)
176
+ this.trackLegacyCursor(element)
136
177
  this.classify(element)
137
178
  if (this.booting) return
138
179
  // Live appends can land out of order (multi-worker hosts broadcast from
@@ -350,7 +391,7 @@ export default class extends Controller {
350
391
  // --- Mark-as-read ------------------------------------------------------------
351
392
 
352
393
  queueRead() {
353
- if (document.visibilityState !== "visible") {
394
+ if (!this.canRead()) {
354
395
  this.pendingRead = true
355
396
  return
356
397
  }
@@ -358,25 +399,63 @@ export default class extends Controller {
358
399
  this.readTimer = setTimeout(() => this.postRead(), 700)
359
400
  }
360
401
 
402
+ // A read that waited for the tab to be seen happens when it is. (What the
403
+ // tab missed while hidden is chats/recovery's to fetch.)
361
404
  visibilityChanged = () => {
362
- if (document.visibilityState === "visible") {
363
- // Asleep long enough for the socket to have been reaped? Catch up on
364
- // anything the missed broadcasts carried.
365
- if (this.hiddenAt && Date.now() - this.hiddenAt > REFRESH_AFTER_HIDDEN_MS) {
366
- this.refreshThread()
367
- }
368
- this.hiddenAt = null
369
- if (this.pendingRead) {
370
- this.pendingRead = false
371
- this.queueRead()
372
- }
373
- } else {
374
- this.hiddenAt = Date.now()
405
+ if (document.visibilityState === "visible" && this.pendingRead) {
406
+ this.pendingRead = false
407
+ this.queueRead()
375
408
  }
376
409
  }
377
410
 
411
+ // Whether the viewer can be reading this thread right now.
412
+ canRead() {
413
+ return document.visibilityState === "visible" && this.engaged() && this.onScreen()
414
+ }
415
+
416
+ // The thread has room to show itself. Geometry alone, for any host that
417
+ // collapses a thread its own way; the dock's own state is engaged()'s job.
418
+ onScreen() {
419
+ return !this.hasScrollerTarget || this.scrollerTarget.clientHeight > 0
420
+ }
421
+
422
+ // Inside a dock window, what the dock says, explicitly: the window is
423
+ // neither minimized (data-chats-minimized) nor waiting for the viewer to
424
+ // turn to it (data-chats-active="false"). Geometry can't be trusted for
425
+ // the first: a folded window's scroller keeps whatever padding a host
426
+ // gives it. No gate (the thread page): always engaged.
427
+ engaged() {
428
+ if (!this.gate) return true
429
+
430
+ return this.gate.dataset.chatsActive !== "false" && !this.gate.hasAttribute("data-chats-minimized")
431
+ }
432
+
433
+ // A read that waited for the thread to be seen happens the moment it is:
434
+ // a minimized window expanding gives its scroller a height, and the dock
435
+ // says when the viewer turns to a window. It lands on the newest message,
436
+ // which is what arrived.
437
+ flushPendingRead = () => {
438
+ if (!this.pendingRead || !this.canRead()) return
439
+ this.pendingRead = false
440
+ this.scrollToBottom()
441
+ this.queueRead()
442
+ }
443
+
444
+ watchScreenPresence() {
445
+ if (!this.hasScrollerTarget || typeof ResizeObserver === "undefined") return
446
+
447
+ this.presenceObserver = new ResizeObserver(this.flushPendingRead)
448
+ this.presenceObserver.observe(this.scrollerTarget)
449
+ }
450
+
378
451
  postRead() {
379
452
  if (!this.readUrlValue) return
453
+ // Asked again when the timer fires: the window may have been minimized
454
+ // (or the tab hidden) in the 700ms since the message arrived.
455
+ if (!this.canRead()) {
456
+ this.pendingRead = true
457
+ return
458
+ }
380
459
  fetch(this.readUrlValue, {
381
460
  method: "POST",
382
461
  headers: { "X-CSRF-Token": csrfToken(), Accept: "application/json" }
@@ -448,45 +527,220 @@ export default class extends Controller {
448
527
 
449
528
  // --- Stale-thread refresh (catch up after sleep/disconnect) --------------------
450
529
 
451
- // The `?since=` cursor: the newest data-updated-at-ms across rendered
452
- // bubbles. updated_at (not created_at) so edits/tombstones made while
453
- // asleep are caught by the refresh too.
454
- trackLoadCursor(element) {
455
- const ms = Number(element.dataset.updatedAtMs || 0)
456
- if (ms > (this.lastLoadedAt || 0)) this.lastLoadedAt = ms
457
- }
458
-
459
- // Reconnect detection without a dedicated cable channel: turbo-rails
460
- // toggles a `connected` attribute on <turbo-cable-stream-source> as the
461
- // Action Cable subscription confirms/drops, so observing that attribute
462
- // IS the heartbeat. (Campfire ran a HeartbeatChannel for this; the
463
- // attribute observer gets the same signal for free.)
464
- watchStreamSource() {
465
- const source = this.element.querySelector("turbo-cable-stream-source")
466
- if (!source || typeof MutationObserver === "undefined") return
467
-
468
- this.sourceObserver = new MutationObserver(() => {
469
- const connected = source.hasAttribute("connected")
470
- if (connected && this.streamDropped) {
471
- this.streamDropped = false
472
- this.refreshThread()
473
- } else if (!connected) {
474
- this.streamDropped = true
530
+ // Two cursors, because "new" and "changed" are different questions: the
531
+ // newest creation (`after`: anything newer is appended) and the newest
532
+ // change (`changed`: anything changed since is replaced). One can't
533
+ // answer both: a message created at 10:15 and missed, while a bubble on
534
+ // screen was edited at 10:20, is older than the newest change. Each is a
535
+ // moment to the microsecond the database keeps (an integer below 2^53,
536
+ // exact as a Number).
537
+ //
538
+ // They are RECONCILED moments: how far the thread knows it has
539
+ // everything. A complete render sets them (the page, a window, a history
540
+ // re-render: the server sends those with it), and so does a catch-up
541
+ // that arrived (the chats_caught_up action); a live bubble never moves
542
+ // them, because it can arrive past one that was missed (a message missed
543
+ // at 10:15, then one live at 10:20: a cursor at 10:20 would skip 10:15
544
+ // for good). What arrived live is named instead, so it isn't sent again:
545
+ // every bubble on screen created since `after`, and every one whose
546
+ // version is exactly the `changed` moment. They live on the element
547
+ // (data-chats-reconciled): the dock moves its windows, and a Turbo
548
+ // snapshot is restored, without losing them.
549
+ cursorsOfRender() {
550
+ const created = this.messageTargets.map((message) => Number(message.dataset.createdAtUs || 0))
551
+ const updated = this.messageTargets.map((message) => Number(message.dataset.updatedAtUs || 0))
552
+ const changed = Math.max(0, ...updated)
553
+ return {
554
+ after: Math.max(0, ...created),
555
+ changed,
556
+ changedIds: this.messageTargets.filter((_, index) => updated[index] === changed).map((message) => message.dataset.messageId)
557
+ }
558
+ }
559
+
560
+ // `data-chats-stale`: this thread is known to be behind. On the element,
561
+ // not the controller, because the element is what the dock carries from
562
+ // page to page (and what a long page render keeps away): the mark can't
563
+ // be forgotten while the thread exists, and goes when it does.
564
+ get stale() {
565
+ return "chatsStale" in this.element.dataset
566
+ }
567
+
568
+ set stale(behind) {
569
+ if (behind) {
570
+ this.element.dataset.chatsStale = ""
571
+ } else {
572
+ delete this.element.dataset.chatsStale
573
+ }
574
+ }
575
+
576
+ // Every catch-up is numbered on the element (data-chats-catch-up), so the
577
+ // count survives the dock carrying the thread from page to page.
578
+ startCatchUp() {
579
+ const attempt = this.latestCatchUp + 1
580
+ this.element.dataset.chatsCatchUp = String(attempt)
581
+ return attempt
582
+ }
583
+
584
+ get latestCatchUp() {
585
+ return Number(this.element.dataset.chatsCatchUp || 0)
586
+ }
587
+
588
+ // The newest catch-up whose answer has started applying
589
+ // (data-chats-applied): nothing older may apply after it.
590
+ get latestApplied() {
591
+ return Number(this.element.dataset.chatsApplied || 0)
592
+ }
593
+
594
+ // An answer carries the render it was asked from (`render`): one for an
595
+ // earlier render of this conversation (the page or the window rendered it
596
+ // again while the answer was on its way) would move the new render's
597
+ // cursors to the old one's, and is ignored.
598
+ caughtUp = (event) => {
599
+ const { render, ...cursors } = event.detail
600
+ if (render && render !== this.element.dataset.chatsRender) return
601
+
602
+ this.reconciled = cursors
603
+ this.element.dataset.chatsReconciled = JSON.stringify(cursors)
604
+ }
605
+
606
+ storedCursors() {
607
+ try {
608
+ return JSON.parse(this.element.dataset.chatsReconciled || "null")
609
+ } catch {
610
+ return null
611
+ }
612
+ }
613
+
614
+ // Bubbles ejected before they carried the cursors above: the one
615
+ // millisecond cursor they had, which moves with every bubble.
616
+ trackLegacyCursor(element) {
617
+ const legacy = Number(element.dataset.updatedAtMs || 0)
618
+ if (legacy > (this.legacyCursor || 0)) this.legacyCursor = legacy
619
+ }
620
+
621
+ // What the stream missed: messages created after the reconciled moment
622
+ // and not on screen (appended), ones on screen changed since (replaced:
623
+ // edits, tombstones, reactions), the read state, and — when messages are
624
+ // hard-deleted — which rendered bubbles still exist. An empty thread asks
625
+ // from the epoch: its first message may be the one it missed. `from` is
626
+ // the oldest rendered bubble, so the server can name every bubble that
627
+ // should be on screen. A thread more than a page behind, or naming more
628
+ // than CURSOR_IDS bubbles, gets its history re-rendered at the latest
629
+ // page instead (`history`: this thread's scroller has the id that takes
630
+ // it; one ejected before it did gets a page refresh); it then opens at
631
+ // the bottom, as a fresh thread does, and asks once more from there, for
632
+ // anything that arrived live while the history was being re-rendered.
633
+ refreshThread({ followUp = true } = {}) {
634
+ if (!this.refreshUrlValue) return
635
+
636
+ // Behind until an answer is applied: a catch-up given up for any reason
637
+ // (the page rendered the thread away, the request failed, Turbo never
638
+ // applied it) leaves the mark, and chats/recovery asks again on connect,
639
+ // on the stream's confirmation and after every page render.
640
+ this.stale = true
641
+ const attempt = this.startCatchUp()
642
+ const params = new URLSearchParams()
643
+ if (this.legacyCursor && !this.messageTargets.some((message) => message.dataset.createdAtUs)) {
644
+ params.set("since", this.legacyCursor)
645
+ } else {
646
+ const { after = 0, changed = 0, changedIds = [] } = this.reconciled || {}
647
+ const afterIds = this.messageTargets
648
+ .filter((message) => Number(message.dataset.createdAtUs || 0) >= after)
649
+ .map((message) => message.dataset.messageId)
650
+ const seenChanged = new Set(changedIds)
651
+ this.messageTargets.forEach((message) => {
652
+ if (Number(message.dataset.updatedAtUs || 0) === changed) seenChanged.add(message.dataset.messageId)
653
+ })
654
+
655
+ params.set("after", after)
656
+ params.set("changed", changed)
657
+ if (afterIds.length > CURSOR_IDS || seenChanged.size > CURSOR_IDS) {
658
+ params.set("full", "1")
659
+ } else {
660
+ if (afterIds.length) params.set("after_ids", afterIds.join(","))
661
+ if (seenChanged.size) params.set("changed_ids", [...seenChanged].join(","))
475
662
  }
663
+ }
664
+ const oldest = this.messageTargets[0]
665
+ if (oldest?.dataset.createdAtUs) params.set("from", oldest.dataset.createdAtUs)
666
+ if (this.hasScrollerTarget && this.scrollerTarget.id) params.set("history", "1")
667
+ const last = this.messageTargets[this.messageTargets.length - 1]
668
+
669
+ // Bound to this render of the thread (data-chats-render, renewed by
670
+ // every complete render): an answer that arrives after the page or the
671
+ // window rendered the conversation again is dropped unrendered.
672
+ const render = this.element.dataset.chatsRender
673
+ params.set("render", render)
674
+ // …and to being no older than the newest catch-up applied: an answer
675
+ // that arrives (or reaches a stream) after a newer one has applied would
676
+ // put older bubbles, read state and cursors over the newer ones.
677
+ const current = () => this.element.isConnected && this.element.dataset.chatsRender === render &&
678
+ attempt >= this.latestApplied
679
+
680
+ // Resolves once Turbo has applied the answer, so `last` is gone by then
681
+ // if the history was re-rendered.
682
+ const applying = () => { if (attempt > this.latestApplied) this.element.dataset.chatsApplied = String(attempt) }
683
+ return renderStreamFrom(`${this.refreshUrlValue}?${params}`, {
684
+ when: current, key: this.element, order: attempt, applying
685
+ }).then((rendered) => {
686
+ if (!rendered) return
687
+ applying() // (an answer with no stream to apply has applied all the same)
688
+ // Only the newest catch-up's answer says the thread is up to date: an
689
+ // older one that applies after a newer one was asked (and maybe
690
+ // failed) still renders what it brought, and leaves the mark.
691
+ if (attempt === this.latestCatchUp) this.stale = false
692
+ if (!last || last.isConnected) return
693
+
694
+ this.element.dataset.chatsRender = newRender() // the re-rendered history is a new render
695
+ this.scrollToBottom()
696
+ if (followUp) return this.refreshThread({ followUp: false })
476
697
  })
477
- this.sourceObserver.observe(source, { attributes: true, attributeFilter: ["connected"] })
478
698
  }
479
699
 
480
- refreshThread() {
481
- if (!this.refreshUrlValue || !this.lastLoadedAt) return
700
+ // A hard delete the stream missed: the bubble is gone from the server, so
701
+ // it goes from here. Only bubbles created before `until`, which the server
702
+ // sets a little before it listed what exists: a message whose insert had
703
+ // not committed yet when it looked, and which then arrived live, is new,
704
+ // not deleted.
705
+ prune = (event) => {
706
+ const present = new Set(event.detail.ids)
707
+ this.messageTargets.forEach((message) => {
708
+ if (Number(message.dataset.createdAtUs || Infinity) > event.detail.until) return
709
+ if (!present.has(message.id)) message.remove()
710
+ })
711
+ }
482
712
 
483
- const url = `${this.refreshUrlValue}?since=${this.lastLoadedAt}`
484
- fetch(url, { headers: { Accept: "text/vnd.turbo-stream.html" } })
485
- .then((response) => (response.ok ? response.text() : ""))
486
- .then((html) => {
487
- if (html) window.Turbo?.renderStreamMessage(html)
713
+ // The cursors a catch-up (or a history re-render) leaves the thread at.
714
+ registerCaughtUpStreamAction() {
715
+ const Turbo = window.Turbo
716
+ if (!Turbo || Turbo.StreamActions.chats_caught_up) return
717
+
718
+ Turbo.StreamActions.chats_caught_up = function () {
719
+ const detail = {
720
+ render: this.getAttribute("data-render") || null,
721
+ after: Number(this.getAttribute("data-after") || 0),
722
+ changed: Number(this.getAttribute("data-changed") || 0),
723
+ changedIds: (this.getAttribute("data-changed-ids") || "").split(",").filter(Boolean)
724
+ }
725
+ this.targetElements.forEach((target) => {
726
+ target.dispatchEvent(new CustomEvent("chats:caught-up", { bubbles: true, detail }))
488
727
  })
489
- .catch(() => {})
728
+ }
729
+ }
730
+
731
+ registerPruneStreamAction() {
732
+ const Turbo = window.Turbo
733
+ if (!Turbo || Turbo.StreamActions.chats_prune) return
734
+
735
+ Turbo.StreamActions.chats_prune = function () {
736
+ const detail = {
737
+ ids: (this.getAttribute("data-ids") || "").split(" ").filter(Boolean),
738
+ until: Number(this.getAttribute("data-until") || 0)
739
+ }
740
+ this.targetElements.forEach((target) => {
741
+ target.dispatchEvent(new CustomEvent("chats:prune", { bubbles: true, detail }))
742
+ })
743
+ }
490
744
  }
491
745
 
492
746
  // --- DOM budget -----------------------------------------------------------------
@@ -822,8 +1076,11 @@ export default class extends Controller {
822
1076
  const messageId = bubble.id.split("_").pop()
823
1077
 
824
1078
  this.closePopup()
1079
+ // `thread`: a page can hold several threads (the page's and the dock's
1080
+ // windows), each with its own composer listening on window — the
1081
+ // composer inside THIS thread is the one that edits.
825
1082
  window.dispatchEvent(new CustomEvent("chats:edit-message", {
826
- detail: { id: messageId, body: body }
1083
+ detail: { id: messageId, body: body, thread: this.element }
827
1084
  }))
828
1085
  }
829
1086
 
@@ -0,0 +1,24 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
3
+
4
+ // chats--unread-badge — the unread badge (`chats_unread_badge`) never sits
5
+ // on an old count.
6
+ //
7
+ // The badge updates live on the viewer's badge stream. When that stream may
8
+ // have missed an update (chats/recovery: a reconnect, a return after 10
9
+ // seconds hidden, the network back, a restored page), this asks GET /badge
10
+ // for every unread badge on the page, the dock's included, and renders
11
+ // them. Nothing is fetched while the stream is healthy.
12
+ //
13
+ // Registered as "chats--unread-badge" through the engine's importmap pin.
14
+ export default class extends Controller {
15
+ static values = { url: String }
16
+
17
+ connect() {
18
+ this.stopRecovery = recoverMissedBroadcasts(this.element, () => renderStreamFrom(this.urlValue))
19
+ }
20
+
21
+ disconnect() {
22
+ this.stopRecovery()
23
+ }
24
+ }