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
@@ -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
  }
@@ -0,0 +1,97 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // chats--split — the two-pane messaging page (`config.inbox_layout = :split`).
4
+ //
5
+ // chats.css lays the page out on its own, with one media query: both panes
6
+ // from 700px wide, one below. What CSS can't decide is where a row's link
7
+ // goes, and that is all this does:
8
+ //
9
+ // * wide: the conversation frame is enabled, so a row opens its
10
+ // conversation beside the list, in one request for the pane alone, and
11
+ // the address bar moves to /messages/:id (the frame's
12
+ // data-turbo-action="advance");
13
+ // * narrow: the frame stays `disabled`, so the same link opens the
14
+ // conversation's page, which then fills the screen. A disabled frame is
15
+ // one Turbo never navigates, links naming it included. And no hover
16
+ // prefetch: the link names the frame, so Turbo would ask for the pane
17
+ // alone, and the page visit would then render that answer as the page.
18
+ //
19
+ // The server always renders the frame disabled, because a page visit is
20
+ // right at any width: it is what a click does before this connects, and
21
+ // without JavaScript. So the page's refreshes (Turbo 8 morphs, every live
22
+ // update of the list) would disable it again on a wide screen; the frame
23
+ // keeps what the screen says.
24
+ //
25
+ // When the pane opens a conversation, its row becomes the current one
26
+ // (aria-current="page", .chats-row--current) at once, exactly as the server
27
+ // renders /messages/:id, instead of when the list next refreshes. And the
28
+ // focus goes where the viewer is now, as LinkedIn's messaging page does:
29
+ // into the conversation's composer, ready to reply. Entering the pane, a
30
+ // screen reader names it (a section labelled by the conversation's title),
31
+ // then the field. On a touch screen the composer would raise the keyboard
32
+ // over a conversation not read yet, so the focus goes to the pane's title
33
+ // instead (tabindex="-1"), which is read out the same; and to the title
34
+ // too when there is nothing to write in (a locked conversation).
35
+ //
36
+ // Registered as "chats--split" through the engine's importmap pin.
37
+ const WIDE = "(min-width: 700px)" // keep in step with chats.css
38
+ const TOUCH = "(pointer: coarse)"
39
+
40
+ export default class extends Controller {
41
+ static targets = ["frame", "list"]
42
+
43
+ connect() {
44
+ this.media = window.matchMedia?.(WIDE)
45
+ this.media?.addEventListener?.("change", this.layout)
46
+ this.layout()
47
+ }
48
+
49
+ disconnect() {
50
+ this.media?.removeEventListener?.("change", this.layout)
51
+ }
52
+
53
+ layout = () => {
54
+ if (this.hasFrameTarget) this.frameTarget.toggleAttribute("disabled", !this.media?.matches)
55
+ }
56
+
57
+ // turbo:before-morph-attribute: a refresh leaves the frame as the screen has it.
58
+ keepEnabled(event) {
59
+ if (event.target === this.frameTarget && event.detail?.attributeName === "disabled") event.preventDefault()
60
+ }
61
+
62
+ // turbo:before-prefetch: nothing names a frame that can't take it.
63
+ prefetch(event) {
64
+ if (!this.hasFrameTarget || !this.frameTarget.hasAttribute("disabled")) return
65
+ if (event.target?.getAttribute?.("data-turbo-frame") === this.frameTarget.id) event.preventDefault()
66
+ }
67
+
68
+ // turbo:frame-load: the pane opened a conversation. Its row is current,
69
+ // and the viewer is taken to it.
70
+ opened(event) {
71
+ if (!this.hasFrameTarget || event.target !== this.frameTarget || !this.hasListTarget) return
72
+ const src = this.frameTarget.getAttribute("src")
73
+ if (!src) return
74
+
75
+ const open = new URL(src, document.baseURI).pathname
76
+ this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
77
+ const current = new URL(link.getAttribute("href"), document.baseURI).pathname === open
78
+ if (current) {
79
+ link.setAttribute("aria-current", "page")
80
+ } else {
81
+ link.removeAttribute("aria-current")
82
+ }
83
+ link.closest(".chats-row")?.classList.toggle("chats-row--current", current)
84
+ })
85
+ this.arrive()
86
+ }
87
+
88
+ arrive() {
89
+ const pane = this.frameTarget.querySelector(".chats-split__pane")
90
+ if (!pane) return
91
+
92
+ const touch = Boolean(window.matchMedia?.(TOUCH)?.matches)
93
+ const composer = touch ? null : pane.querySelector(".chats-composer__input")
94
+ const title = pane.querySelector(`[id="${pane.getAttribute("aria-labelledby")}"]`)
95
+ ;(composer || title)?.focus({ preventScroll: true })
96
+ }
97
+ }
@@ -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.
@@ -88,6 +96,10 @@ export default class extends Controller {
88
96
 
89
97
  connect() {
90
98
  this.registerTypingStreamAction()
99
+ this.registerPruneStreamAction()
100
+ this.registerCaughtUpStreamAction()
101
+ this.element.dataset.chatsRender ||= newRender()
102
+ this.caughtUp({ detail: this.storedCursors() || this.cursorsOfRender() })
91
103
  // The dock's engagement gate: the window frame around a dock thread.
92
104
  // No gate (the thread page) means always engaged.
93
105
  this.gate = this.element.closest("[data-chats-active]")
@@ -124,7 +136,9 @@ export default class extends Controller {
124
136
  event.stopPropagation()
125
137
  }
126
138
  this.element.addEventListener("click", this.suppressClickCapture, true)
127
- 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 })
128
142
  this.watchScreenPresence()
129
143
  }
130
144
 
@@ -134,7 +148,17 @@ export default class extends Controller {
134
148
  }
135
149
  document.removeEventListener("visibilitychange", this.visibilityChanged)
136
150
  clearTimeout(this.readTimer)
137
- 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)
138
162
  this.presenceObserver?.disconnect()
139
163
  this.gate?.removeEventListener("chats:activated", this.flushPendingRead)
140
164
  this.element.removeEventListener("click", this.suppressClickCapture, true)
@@ -149,7 +173,7 @@ export default class extends Controller {
149
173
  // --- Bubbles ---------------------------------------------------------------
150
174
 
151
175
  messageTargetConnected(element) {
152
- this.trackLoadCursor(element)
176
+ this.trackLegacyCursor(element)
153
177
  this.classify(element)
154
178
  if (this.booting) return
155
179
  // Live appends can land out of order (multi-worker hosts broadcast from
@@ -375,20 +399,12 @@ export default class extends Controller {
375
399
  this.readTimer = setTimeout(() => this.postRead(), 700)
376
400
  }
377
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.)
378
404
  visibilityChanged = () => {
379
- if (document.visibilityState === "visible") {
380
- // Asleep long enough for the socket to have been reaped? Catch up on
381
- // anything the missed broadcasts carried.
382
- if (this.hiddenAt && Date.now() - this.hiddenAt > REFRESH_AFTER_HIDDEN_MS) {
383
- this.refreshThread()
384
- }
385
- this.hiddenAt = null
386
- if (this.pendingRead) {
387
- this.pendingRead = false
388
- this.queueRead()
389
- }
390
- } else {
391
- this.hiddenAt = Date.now()
405
+ if (document.visibilityState === "visible" && this.pendingRead) {
406
+ this.pendingRead = false
407
+ this.queueRead()
392
408
  }
393
409
  }
394
410
 
@@ -511,45 +527,220 @@ export default class extends Controller {
511
527
 
512
528
  // --- Stale-thread refresh (catch up after sleep/disconnect) --------------------
513
529
 
514
- // The `?since=` cursor: the newest data-updated-at-ms across rendered
515
- // bubbles. updated_at (not created_at) so edits/tombstones made while
516
- // asleep are caught by the refresh too.
517
- trackLoadCursor(element) {
518
- const ms = Number(element.dataset.updatedAtMs || 0)
519
- if (ms > (this.lastLoadedAt || 0)) this.lastLoadedAt = ms
520
- }
521
-
522
- // Reconnect detection without a dedicated cable channel: turbo-rails
523
- // toggles a `connected` attribute on <turbo-cable-stream-source> as the
524
- // Action Cable subscription confirms/drops, so observing that attribute
525
- // IS the heartbeat. (Campfire ran a HeartbeatChannel for this; the
526
- // attribute observer gets the same signal for free.)
527
- watchStreamSource() {
528
- const source = this.element.querySelector("turbo-cable-stream-source")
529
- if (!source || typeof MutationObserver === "undefined") return
530
-
531
- this.sourceObserver = new MutationObserver(() => {
532
- const connected = source.hasAttribute("connected")
533
- if (connected && this.streamDropped) {
534
- this.streamDropped = false
535
- this.refreshThread()
536
- } else if (!connected) {
537
- 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(","))
538
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 })
697
+ })
698
+ }
699
+
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()
539
710
  })
540
- this.sourceObserver.observe(source, { attributes: true, attributeFilter: ["connected"] })
541
711
  }
542
712
 
543
- refreshThread() {
544
- if (!this.refreshUrlValue || !this.lastLoadedAt) return
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 }))
727
+ })
728
+ }
729
+ }
730
+
731
+ registerPruneStreamAction() {
732
+ const Turbo = window.Turbo
733
+ if (!Turbo || Turbo.StreamActions.chats_prune) return
545
734
 
546
- const url = `${this.refreshUrlValue}?since=${this.lastLoadedAt}`
547
- fetch(url, { headers: { Accept: "text/vnd.turbo-stream.html" } })
548
- .then((response) => (response.ok ? response.text() : ""))
549
- .then((html) => {
550
- if (html) window.Turbo?.renderStreamMessage(html)
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 }))
551
742
  })
552
- .catch(() => {})
743
+ }
553
744
  }
554
745
 
555
746
  // --- DOM budget -----------------------------------------------------------------
@@ -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
+ }