chats 0.1.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1037 -0
  3. data/README.md +621 -40
  4. data/app/assets/stylesheets/chats.css +906 -2
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +396 -86
  7. data/app/controllers/chats/messages_controller.rb +35 -13
  8. data/app/controllers/chats/reactions_controller.rb +14 -0
  9. data/app/helpers/chats/engine_helper.rb +230 -7
  10. data/app/javascript/chats/composer_controller.js +14 -1
  11. data/app/javascript/chats/dock_controller.js +1120 -0
  12. data/app/javascript/chats/recovery.js +539 -0
  13. data/app/javascript/chats/refresh_inbox_controller.js +76 -0
  14. data/app/javascript/chats/split_controller.js +131 -0
  15. data/app/javascript/chats/thread_controller.js +322 -60
  16. data/app/javascript/chats/unread_badge_controller.js +24 -0
  17. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  18. data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
  19. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  20. data/app/views/chats/conversations/_group.html.erb +61 -0
  21. data/app/views/chats/conversations/_history.html.erb +54 -0
  22. data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
  23. data/app/views/chats/conversations/_thread.html.erb +147 -0
  24. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  25. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  26. data/app/views/chats/conversations/dock.html.erb +40 -0
  27. data/app/views/chats/conversations/index.html.erb +142 -15
  28. data/app/views/chats/conversations/pane.html.erb +14 -0
  29. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  30. data/app/views/chats/conversations/show.html.erb +18 -134
  31. data/app/views/chats/conversations/window.html.erb +26 -0
  32. data/app/views/chats/dock/_badge.html.erb +12 -0
  33. data/app/views/chats/dock/_dock.html.erb +139 -0
  34. data/app/views/chats/dock/_signal.html.erb +26 -0
  35. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  36. data/app/views/chats/dock/_window.html.erb +23 -0
  37. data/app/views/chats/dock/_window_header.html.erb +75 -0
  38. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  39. data/app/views/chats/messages/_action.html.erb +15 -0
  40. data/app/views/chats/messages/_composer.html.erb +84 -56
  41. data/app/views/chats/messages/_message.html.erb +43 -15
  42. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  43. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  44. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  45. data/config/importmap.rb +10 -1
  46. data/config/locales/en.yml +40 -0
  47. data/config/locales/es.yml +40 -0
  48. data/config/routes.rb +8 -0
  49. data/context7.json +4 -0
  50. data/docs/PRD.md +1 -1
  51. data/docs/campfire_review.md +1 -1
  52. data/docs/screenshots/composer-actions.png +0 -0
  53. data/docs/screenshots/dock-bar.png +0 -0
  54. data/docs/screenshots/dock-desktop.png +0 -0
  55. data/docs/screenshots/dock-phone-button.png +0 -0
  56. data/docs/screenshots/dock-phone-panel.png +0 -0
  57. data/docs/screenshots/dock-phone-window.png +0 -0
  58. data/docs/screenshots/inbox.png +0 -0
  59. data/docs/screenshots/split-inbox-empty.png +0 -0
  60. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  61. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  62. data/docs/screenshots/split-inbox.png +0 -0
  63. data/docs/screenshots/system-note-action.png +0 -0
  64. data/docs/screenshots/thread-actions-phone.png +0 -0
  65. data/gemfiles/rails_7.1.gemfile +3 -0
  66. data/gemfiles/rails_7.2.gemfile +3 -0
  67. data/gemfiles/rails_8.1.gemfile +3 -0
  68. data/lib/chats/action.rb +85 -0
  69. data/lib/chats/broadcasts.rb +178 -17
  70. data/lib/chats/configuration.rb +115 -1
  71. data/lib/chats/engine.rb +29 -7
  72. data/lib/chats/errors.rb +16 -0
  73. data/lib/chats/inbox.rb +363 -0
  74. data/lib/chats/inbox_group.rb +75 -0
  75. data/lib/chats/inbox_refresh_job.rb +14 -0
  76. data/lib/chats/macros.rb +33 -1
  77. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  78. data/lib/chats/models/concerns/messager.rb +122 -6
  79. data/lib/chats/models/conversation.rb +153 -19
  80. data/lib/chats/models/message.rb +292 -21
  81. data/lib/chats/models/participant.rb +67 -0
  82. data/lib/chats/models/reaction.rb +18 -0
  83. data/lib/chats/send_rate_limited.rb +30 -0
  84. data/lib/chats/subscribers.rb +156 -0
  85. data/lib/chats/version.rb +1 -1
  86. data/lib/chats.rb +123 -15
  87. data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
  88. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  89. data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
  90. data/lib/generators/chats/templates/initializer.rb +125 -14
  91. data/lib/generators/chats/upgrade_generator.rb +79 -0
  92. data/lib/generators/chats/views_generator.rb +3 -2
  93. metadata +51 -2
@@ -0,0 +1,131 @@
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
+ // The tab's title follows the pane: Turbo leaves <head> alone when it
26
+ // promotes a frame load to a visit, so the title would stay the previous
27
+ // page's. The pane carries the page's title with it open (`data-title`,
28
+ // chats_page_title: the same the full page gives `content_for :title`).
29
+ //
30
+ // The open conversation's row stops counting it unread as soon as it is
31
+ // read (the pane's answer says the server read it, `data-read`; or its
32
+ // thread reads it on screen, `chats:read`), without waiting for the list's
33
+ // live refresh, which may be seconds away or not come at all.
34
+ //
35
+ // When the pane opens a conversation, its row becomes the current one
36
+ // (aria-current="page", .chats-row--current) at once, exactly as the server
37
+ // renders /messages/:id, instead of when the list next refreshes. And the
38
+ // focus goes where the viewer is now, as LinkedIn's messaging page does:
39
+ // into the conversation's composer, ready to reply. Entering the pane, a
40
+ // screen reader names it (a section labelled by the conversation's title),
41
+ // then the field. On a touch screen the composer would raise the keyboard
42
+ // over a conversation not read yet, so the focus goes to the pane's title
43
+ // instead (tabindex="-1"), which is read out the same; and to the title
44
+ // too when there is nothing to write in (a locked conversation).
45
+ //
46
+ // Registered as "chats--split" through the engine's importmap pin.
47
+ const WIDE = "(min-width: 700px)" // keep in step with chats.css
48
+ const TOUCH = "(pointer: coarse)"
49
+
50
+ export default class extends Controller {
51
+ static targets = ["frame", "list"]
52
+
53
+ connect() {
54
+ this.media = window.matchMedia?.(WIDE)
55
+ this.media?.addEventListener?.("change", this.layout)
56
+ this.layout()
57
+ }
58
+
59
+ disconnect() {
60
+ this.media?.removeEventListener?.("change", this.layout)
61
+ }
62
+
63
+ layout = () => {
64
+ if (this.hasFrameTarget) this.frameTarget.toggleAttribute("disabled", !this.media?.matches)
65
+ }
66
+
67
+ // turbo:before-morph-attribute: a refresh leaves the frame as the screen has it.
68
+ keepEnabled(event) {
69
+ if (event.target === this.frameTarget && event.detail?.attributeName === "disabled") event.preventDefault()
70
+ }
71
+
72
+ // turbo:before-prefetch: nothing names a frame that can't take it.
73
+ prefetch(event) {
74
+ if (!this.hasFrameTarget || !this.frameTarget.hasAttribute("disabled")) return
75
+ if (event.target?.getAttribute?.("data-turbo-frame") === this.frameTarget.id) event.preventDefault()
76
+ }
77
+
78
+ // turbo:frame-load: the pane opened a conversation. Its row is current,
79
+ // and the viewer is taken to it.
80
+ opened(event) {
81
+ if (!this.hasFrameTarget || event.target !== this.frameTarget || !this.hasListTarget) return
82
+ const src = this.frameTarget.getAttribute("src")
83
+ if (!src) return
84
+
85
+ const open = new URL(src, document.baseURI).pathname
86
+ this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
87
+ const current = new URL(link.getAttribute("href"), document.baseURI).pathname === open
88
+ if (current) {
89
+ link.setAttribute("aria-current", "page")
90
+ } else {
91
+ link.removeAttribute("aria-current")
92
+ }
93
+ link.closest(".chats-row")?.classList.toggle("chats-row--current", current)
94
+ })
95
+ const pane = this.frameTarget.querySelector(".chats-split__pane")
96
+ if (pane?.dataset.title) document.title = pane.dataset.title
97
+ if (pane?.dataset.read === "true") this.markRead(pane)
98
+ this.arrive()
99
+ }
100
+
101
+ // chats:read, from the pane's thread.
102
+ threadRead(event) {
103
+ const pane = event.target?.closest?.(".chats-split__pane")
104
+ if (pane) this.markRead(pane)
105
+ }
106
+
107
+ // The conversation's row, read: no count, no bold, as the next refresh
108
+ // will render it.
109
+ markRead(pane) {
110
+ if (!this.hasListTarget || !pane.dataset.path) return
111
+
112
+ const path = new URL(pane.dataset.path, document.baseURI).pathname
113
+ this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
114
+ if (new URL(link.getAttribute("href"), document.baseURI).pathname !== path) return
115
+
116
+ const row = link.closest(".chats-row")
117
+ row?.classList.remove("chats-row--unread")
118
+ row?.querySelector(".chats-row__bottom .chats-badge")?.remove()
119
+ })
120
+ }
121
+
122
+ arrive() {
123
+ const pane = this.frameTarget.querySelector(".chats-split__pane")
124
+ if (!pane) return
125
+
126
+ const touch = Boolean(window.matchMedia?.(TOUCH)?.matches)
127
+ const composer = touch ? null : pane.querySelector(".chats-composer__input")
128
+ const title = pane.querySelector(`[id="${pane.getAttribute("aria-labelledby")}"]`)
129
+ ;(composer || title)?.focus({ preventScroll: true })
130
+ }
131
+ }
@@ -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,28 +399,71 @@ 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
+ }
459
+ // chats:read, once the server has it: the two-pane page clears the
460
+ // conversation's row (chats--split) without waiting for the list's
461
+ // live refresh.
380
462
  fetch(this.readUrlValue, {
381
463
  method: "POST",
382
464
  headers: { "X-CSRF-Token": csrfToken(), Accept: "application/json" }
465
+ }).then((response) => {
466
+ if (response.ok) this.element.dispatchEvent(new CustomEvent("chats:read", { bubbles: true }))
383
467
  }).catch(() => {})
384
468
  }
385
469
 
@@ -448,45 +532,220 @@ export default class extends Controller {
448
532
 
449
533
  // --- Stale-thread refresh (catch up after sleep/disconnect) --------------------
450
534
 
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
535
+ // Two cursors, because "new" and "changed" are different questions: the
536
+ // newest creation (`after`: anything newer is appended) and the newest
537
+ // change (`changed`: anything changed since is replaced). One can't
538
+ // answer both: a message created at 10:15 and missed, while a bubble on
539
+ // screen was edited at 10:20, is older than the newest change. Each is a
540
+ // moment to the microsecond the database keeps (an integer below 2^53,
541
+ // exact as a Number).
542
+ //
543
+ // They are RECONCILED moments: how far the thread knows it has
544
+ // everything. A complete render sets them (the page, a window, a history
545
+ // re-render: the server sends those with it), and so does a catch-up
546
+ // that arrived (the chats_caught_up action); a live bubble never moves
547
+ // them, because it can arrive past one that was missed (a message missed
548
+ // at 10:15, then one live at 10:20: a cursor at 10:20 would skip 10:15
549
+ // for good). What arrived live is named instead, so it isn't sent again:
550
+ // every bubble on screen created since `after`, and every one whose
551
+ // version is exactly the `changed` moment. They live on the element
552
+ // (data-chats-reconciled): the dock moves its windows, and a Turbo
553
+ // snapshot is restored, without losing them.
554
+ cursorsOfRender() {
555
+ const created = this.messageTargets.map((message) => Number(message.dataset.createdAtUs || 0))
556
+ const updated = this.messageTargets.map((message) => Number(message.dataset.updatedAtUs || 0))
557
+ const changed = Math.max(0, ...updated)
558
+ return {
559
+ after: Math.max(0, ...created),
560
+ changed,
561
+ changedIds: this.messageTargets.filter((_, index) => updated[index] === changed).map((message) => message.dataset.messageId)
562
+ }
563
+ }
564
+
565
+ // `data-chats-stale`: this thread is known to be behind. On the element,
566
+ // not the controller, because the element is what the dock carries from
567
+ // page to page (and what a long page render keeps away): the mark can't
568
+ // be forgotten while the thread exists, and goes when it does.
569
+ get stale() {
570
+ return "chatsStale" in this.element.dataset
571
+ }
572
+
573
+ set stale(behind) {
574
+ if (behind) {
575
+ this.element.dataset.chatsStale = ""
576
+ } else {
577
+ delete this.element.dataset.chatsStale
578
+ }
579
+ }
580
+
581
+ // Every catch-up is numbered on the element (data-chats-catch-up), so the
582
+ // count survives the dock carrying the thread from page to page.
583
+ startCatchUp() {
584
+ const attempt = this.latestCatchUp + 1
585
+ this.element.dataset.chatsCatchUp = String(attempt)
586
+ return attempt
587
+ }
588
+
589
+ get latestCatchUp() {
590
+ return Number(this.element.dataset.chatsCatchUp || 0)
591
+ }
592
+
593
+ // The newest catch-up whose answer has started applying
594
+ // (data-chats-applied): nothing older may apply after it.
595
+ get latestApplied() {
596
+ return Number(this.element.dataset.chatsApplied || 0)
597
+ }
598
+
599
+ // An answer carries the render it was asked from (`render`): one for an
600
+ // earlier render of this conversation (the page or the window rendered it
601
+ // again while the answer was on its way) would move the new render's
602
+ // cursors to the old one's, and is ignored.
603
+ caughtUp = (event) => {
604
+ const { render, ...cursors } = event.detail
605
+ if (render && render !== this.element.dataset.chatsRender) return
606
+
607
+ this.reconciled = cursors
608
+ this.element.dataset.chatsReconciled = JSON.stringify(cursors)
609
+ }
610
+
611
+ storedCursors() {
612
+ try {
613
+ return JSON.parse(this.element.dataset.chatsReconciled || "null")
614
+ } catch {
615
+ return null
616
+ }
617
+ }
618
+
619
+ // Bubbles ejected before they carried the cursors above: the one
620
+ // millisecond cursor they had, which moves with every bubble.
621
+ trackLegacyCursor(element) {
622
+ const legacy = Number(element.dataset.updatedAtMs || 0)
623
+ if (legacy > (this.legacyCursor || 0)) this.legacyCursor = legacy
624
+ }
625
+
626
+ // What the stream missed: messages created after the reconciled moment
627
+ // and not on screen (appended), ones on screen changed since (replaced:
628
+ // edits, tombstones, reactions), the read state, and — when messages are
629
+ // hard-deleted — which rendered bubbles still exist. An empty thread asks
630
+ // from the epoch: its first message may be the one it missed. `from` is
631
+ // the oldest rendered bubble, so the server can name every bubble that
632
+ // should be on screen. A thread more than a page behind, or naming more
633
+ // than CURSOR_IDS bubbles, gets its history re-rendered at the latest
634
+ // page instead (`history`: this thread's scroller has the id that takes
635
+ // it; one ejected before it did gets a page refresh); it then opens at
636
+ // the bottom, as a fresh thread does, and asks once more from there, for
637
+ // anything that arrived live while the history was being re-rendered.
638
+ refreshThread({ followUp = true } = {}) {
639
+ if (!this.refreshUrlValue) return
640
+
641
+ // Behind until an answer is applied: a catch-up given up for any reason
642
+ // (the page rendered the thread away, the request failed, Turbo never
643
+ // applied it) leaves the mark, and chats/recovery asks again on connect,
644
+ // on the stream's confirmation and after every page render.
645
+ this.stale = true
646
+ const attempt = this.startCatchUp()
647
+ const params = new URLSearchParams()
648
+ if (this.legacyCursor && !this.messageTargets.some((message) => message.dataset.createdAtUs)) {
649
+ params.set("since", this.legacyCursor)
650
+ } else {
651
+ const { after = 0, changed = 0, changedIds = [] } = this.reconciled || {}
652
+ const afterIds = this.messageTargets
653
+ .filter((message) => Number(message.dataset.createdAtUs || 0) >= after)
654
+ .map((message) => message.dataset.messageId)
655
+ const seenChanged = new Set(changedIds)
656
+ this.messageTargets.forEach((message) => {
657
+ if (Number(message.dataset.updatedAtUs || 0) === changed) seenChanged.add(message.dataset.messageId)
658
+ })
659
+
660
+ params.set("after", after)
661
+ params.set("changed", changed)
662
+ if (afterIds.length > CURSOR_IDS || seenChanged.size > CURSOR_IDS) {
663
+ params.set("full", "1")
664
+ } else {
665
+ if (afterIds.length) params.set("after_ids", afterIds.join(","))
666
+ if (seenChanged.size) params.set("changed_ids", [...seenChanged].join(","))
475
667
  }
668
+ }
669
+ const oldest = this.messageTargets[0]
670
+ if (oldest?.dataset.createdAtUs) params.set("from", oldest.dataset.createdAtUs)
671
+ if (this.hasScrollerTarget && this.scrollerTarget.id) params.set("history", "1")
672
+ const last = this.messageTargets[this.messageTargets.length - 1]
673
+
674
+ // Bound to this render of the thread (data-chats-render, renewed by
675
+ // every complete render): an answer that arrives after the page or the
676
+ // window rendered the conversation again is dropped unrendered.
677
+ const render = this.element.dataset.chatsRender
678
+ params.set("render", render)
679
+ // …and to being no older than the newest catch-up applied: an answer
680
+ // that arrives (or reaches a stream) after a newer one has applied would
681
+ // put older bubbles, read state and cursors over the newer ones.
682
+ const current = () => this.element.isConnected && this.element.dataset.chatsRender === render &&
683
+ attempt >= this.latestApplied
684
+
685
+ // Resolves once Turbo has applied the answer, so `last` is gone by then
686
+ // if the history was re-rendered.
687
+ const applying = () => { if (attempt > this.latestApplied) this.element.dataset.chatsApplied = String(attempt) }
688
+ return renderStreamFrom(`${this.refreshUrlValue}?${params}`, {
689
+ when: current, key: this.element, order: attempt, applying
690
+ }).then((rendered) => {
691
+ if (!rendered) return
692
+ applying() // (an answer with no stream to apply has applied all the same)
693
+ // Only the newest catch-up's answer says the thread is up to date: an
694
+ // older one that applies after a newer one was asked (and maybe
695
+ // failed) still renders what it brought, and leaves the mark.
696
+ if (attempt === this.latestCatchUp) this.stale = false
697
+ if (!last || last.isConnected) return
698
+
699
+ this.element.dataset.chatsRender = newRender() // the re-rendered history is a new render
700
+ this.scrollToBottom()
701
+ if (followUp) return this.refreshThread({ followUp: false })
476
702
  })
477
- this.sourceObserver.observe(source, { attributes: true, attributeFilter: ["connected"] })
478
703
  }
479
704
 
480
- refreshThread() {
481
- if (!this.refreshUrlValue || !this.lastLoadedAt) return
705
+ // A hard delete the stream missed: the bubble is gone from the server, so
706
+ // it goes from here. Only bubbles created before `until`, which the server
707
+ // sets a little before it listed what exists: a message whose insert had
708
+ // not committed yet when it looked, and which then arrived live, is new,
709
+ // not deleted.
710
+ prune = (event) => {
711
+ const present = new Set(event.detail.ids)
712
+ this.messageTargets.forEach((message) => {
713
+ if (Number(message.dataset.createdAtUs || Infinity) > event.detail.until) return
714
+ if (!present.has(message.id)) message.remove()
715
+ })
716
+ }
482
717
 
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)
718
+ // The cursors a catch-up (or a history re-render) leaves the thread at.
719
+ registerCaughtUpStreamAction() {
720
+ const Turbo = window.Turbo
721
+ if (!Turbo || Turbo.StreamActions.chats_caught_up) return
722
+
723
+ Turbo.StreamActions.chats_caught_up = function () {
724
+ const detail = {
725
+ render: this.getAttribute("data-render") || null,
726
+ after: Number(this.getAttribute("data-after") || 0),
727
+ changed: Number(this.getAttribute("data-changed") || 0),
728
+ changedIds: (this.getAttribute("data-changed-ids") || "").split(",").filter(Boolean)
729
+ }
730
+ this.targetElements.forEach((target) => {
731
+ target.dispatchEvent(new CustomEvent("chats:caught-up", { bubbles: true, detail }))
488
732
  })
489
- .catch(() => {})
733
+ }
734
+ }
735
+
736
+ registerPruneStreamAction() {
737
+ const Turbo = window.Turbo
738
+ if (!Turbo || Turbo.StreamActions.chats_prune) return
739
+
740
+ Turbo.StreamActions.chats_prune = function () {
741
+ const detail = {
742
+ ids: (this.getAttribute("data-ids") || "").split(" ").filter(Boolean),
743
+ until: Number(this.getAttribute("data-until") || 0)
744
+ }
745
+ this.targetElements.forEach((target) => {
746
+ target.dispatchEvent(new CustomEvent("chats:prune", { bubbles: true, detail }))
747
+ })
748
+ }
490
749
  }
491
750
 
492
751
  // --- DOM budget -----------------------------------------------------------------
@@ -822,8 +1081,11 @@ export default class extends Controller {
822
1081
  const messageId = bubble.id.split("_").pop()
823
1082
 
824
1083
  this.closePopup()
1084
+ // `thread`: a page can hold several threads (the page's and the dock's
1085
+ // windows), each with its own composer listening on window — the
1086
+ // composer inside THIS thread is the one that edits.
825
1087
  window.dispatchEvent(new CustomEvent("chats:edit-message", {
826
- detail: { id: messageId, body: body }
1088
+ detail: { id: messageId, body: body, thread: this.element }
827
1089
  }))
828
1090
  }
829
1091
 
@@ -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
+ }