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,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
2
3
 
3
4
  // chats--dock: the messaging dock (`chats_dock`) — LinkedIn / Steam style
4
5
  // floating conversation windows in the bottom-right corner.
@@ -30,13 +31,22 @@ import { Controller } from "@hotwired/stimulus"
30
31
  // its window pops open (in dock mode only, never stealing
31
32
  // focus, never for one closed in the last five minutes,
32
33
  // never over a window in use) and is announced politely.
34
+ // * catching up what the dock's stream missed (chats/recovery: a
35
+ // reconnect, a return after 10 seconds hidden, the
36
+ // network back, a restored page) reloads the recent list,
37
+ // morphed in place so its scroll and search stay, and
38
+ // fetches the badges again. Each open window's thread
39
+ // catches up by itself. A missed message never pops a
40
+ // window open late: the badge and the list say it.
33
41
  // * memory per viewer in localStorage (`chats:dock:v1:<key>`):
34
42
  // which windows, minimized or not, recently closed ones,
35
43
  // and the panel; drafts under `chats:draft:<key>:<id>`.
36
44
  // Every access is guarded: no storage, no memory, no harm.
37
45
  // * hiding on any page with [data-chats-dock-hide] (the engine's
38
46
  // inbox and thread), since a permanent element is never
39
- // re-rendered by the server
47
+ // re-rendered by the server; and no window for a
48
+ // conversation the page renders, after a visit or after a
49
+ // frame of the page loads one
40
50
  // * keyboard Escape unwinds innermost first: the thread's popup or
41
51
  // photo, an edit, the window menu, then the window
42
52
  // (minimized), the panel (closed); on a phone, the window
@@ -75,7 +85,8 @@ export default class extends Controller {
75
85
  // isolated engine it is "conversation", not "chats_conversation".
76
86
  recordKey: { type: String, default: "conversation" },
77
87
  announcement: String,
78
- windowUnread: String
88
+ windowUnread: String,
89
+ badgeUrl: String
79
90
  }
80
91
 
81
92
  // Stimulus runs the target callbacks (windowTargetConnected…) BEFORE
@@ -103,6 +114,10 @@ export default class extends Controller {
103
114
  this.layout()
104
115
  this.restore()
105
116
  this.pageChanged()
117
+
118
+ // Its own stream only: each window's thread watches its own.
119
+ this.stopRecovery = recoverMissedBroadcasts(this.element, () => this.catchUp(),
120
+ { sources: ":scope > turbo-cable-stream-source" })
106
121
  }
107
122
 
108
123
  disconnect() {
@@ -111,6 +126,7 @@ export default class extends Controller {
111
126
  window.visualViewport?.removeEventListener("resize", this.fitViewport)
112
127
  window.visualViewport?.removeEventListener("scroll", this.fitViewport)
113
128
  this.releaseSheet()
129
+ this.stopRecovery?.()
114
130
  }
115
131
 
116
132
  // --- Opening ---------------------------------------------------------------
@@ -459,6 +475,11 @@ export default class extends Controller {
459
475
  })
460
476
  }
461
477
 
478
+ catchUp() {
479
+ this.refreshList()
480
+ if (this.badgeUrlValue) renderStreamFrom(this.badgeUrlValue)
481
+ }
482
+
462
483
  badgeTargetConnected(badge) {
463
484
  const count = Number(badge.dataset.count || 0)
464
485
  if (this.lastCount !== undefined && count > this.lastCount) this.flash(badge, "chats-badge--bumped", 200)
@@ -763,6 +784,18 @@ export default class extends Controller {
763
784
  this.save()
764
785
  }
765
786
 
787
+ // A frame of the page itself loaded a conversation (the two-pane messaging
788
+ // page opens one beside its list without a visit): no window keeps a
789
+ // second copy of it.
790
+ pageFrameLoaded(event) {
791
+ if (this.element.contains(event.target)) return
792
+
793
+ this.windowTargets.forEach((frame) => {
794
+ if (this.renderedOnPage(frame.dataset.conversationId)) this.removeWindow(frame)
795
+ })
796
+ this.save()
797
+ }
798
+
766
799
  // A row whose conversation is open in a window says so.
767
800
  markOpenRows() {
768
801
  if (!this.hasListTarget) return
@@ -1049,13 +1082,30 @@ export default class extends Controller {
1049
1082
  return content
1050
1083
  }
1051
1084
 
1085
+ // "3 new messages", in the plural form the page's language uses for 3:
1086
+ // Polish says 2, 3, 4, 22 differently from 5 and 1; Russian says 21 like 1.
1087
+ // The dock renders every form its locale has (chats.dock.window_unread:
1088
+ // zero, one, two, few, many, other); a form the locale lacks reads as
1089
+ // `other`.
1052
1090
  unreadLabel(count) {
1091
+ let forms
1053
1092
  try {
1054
- const forms = JSON.parse(this.windowUnreadValue || "{}")
1055
- return (count === 1 ? forms.one : forms.other)?.replace("%{count}", count) || ""
1093
+ forms = JSON.parse(this.windowUnreadValue || "{}")
1056
1094
  } catch {
1057
1095
  return ""
1058
1096
  }
1097
+ return (forms[this.pluralCategory(count)] ?? forms.other)?.replace("%{count}", count) || ""
1098
+ }
1099
+
1100
+ // CLDR's category for +count+ in the page's language (<html lang>, else
1101
+ // the browser's). Without Intl, or with a language tag it can't read, the
1102
+ // old two-form rule.
1103
+ pluralCategory(count) {
1104
+ try {
1105
+ return new Intl.PluralRules(document.documentElement.lang || navigator.language || undefined).select(count)
1106
+ } catch {
1107
+ return count === 1 ? "one" : "other"
1108
+ }
1059
1109
  }
1060
1110
 
1061
1111
  pixels(property, fallback) {
@@ -0,0 +1,539 @@
1
+ // chats/recovery — every live surface catches up on the broadcasts it missed.
2
+ //
3
+ // Action Cable has no replay. A broadcast sent while this page's socket is
4
+ // down (a laptop asleep, a phone in a pocket, a backgrounded Hotwire Native
5
+ // WebView, an elevator) never arrives, and nothing tells the page it missed
6
+ // anything. Turbo's own doctrine is that stream delivery is best effort and a
7
+ // page must be correct without it (https://turbo.hotwired.dev/handbook/streams).
8
+ // So every surface that lives on a stream (the inbox, a thread, the unread
9
+ // badge, the dock) asks for what it missed at the moments it may have missed
10
+ // something:
11
+ //
12
+ // 1. its stream RECONNECTS: its <turbo-cable-stream-source> confirms the
13
+ // subscription again;
14
+ // 2. the page comes BACK after being hidden for 10 seconds or more (an
15
+ // app foregrounded, a tab returned to);
16
+ // 3. the network comes back ONLINE: Action Cable is asked to check its
17
+ // socket now instead of after its backoff (a dead one reconnects, which
18
+ // is trigger 1), and the surface catches up anyway: after a short
19
+ // outage the socket can still look open while what was sent during it
20
+ // is gone;
21
+ // 4. the browser RESTORES the page from its back/forward cache.
22
+ //
23
+ // Never otherwise. While the socket is healthy and the page is on screen
24
+ // nothing is fetched: there is no polling.
25
+ //
26
+ // connect() { this.stopRecovery = recoverMissedBroadcasts(this.element, () => this.catchUp()) }
27
+ // disconnect() { this.stopRecovery() }
28
+ //
29
+ // Triggers that come together (an app foregrounding fires 2 and then 1) make
30
+ // one catch-up, and two catch-ups are at least five seconds apart, so a
31
+ // flapping connection can't turn into a fetch loop. A page that is hidden
32
+ // never fetches: what it should catch up on waits until it is seen.
33
+ //
34
+ // How a reconnect is seen. turbo-rails sets a `connected` attribute on the
35
+ // stream source each time Action Cable confirms its subscription, and removes
36
+ // it when the socket closes. Every confirmation after the first is a
37
+ // reconnect, whether or not the close was ever reported: when Action Cable
38
+ // reopens a socket that went quiet (the half-open TCP of a phone that slept),
39
+ // it swaps the old socket's handlers out before that socket's close event can
40
+ // arrive, so subscribers never hear "disconnected", only "connected" again —
41
+ // and its `reconnected: true` detail goes to the first subscription confirmed
42
+ // and nobody else (actioncable/app/javascript/action_cable/connection.js).
43
+ // Watching the stream's OWN confirmations, instead of a separate heartbeat
44
+ // subscription, also means the catch-up fetch starts only once this stream
45
+ // is live again, so nothing can fall between the two.
46
+ //
47
+ // A page Turbo restores from its snapshot cache (a back navigation, a
48
+ // Hotwire Native stack pop) is as old as the moment it was left, and no
49
+ // request is made for it. Its stream sources are marked as they go into the
50
+ // cache (`chats-cached`, set on `turbo:before-cache`), so when one comes
51
+ // back its first confirmation counts as a reconnect: a restored page catches
52
+ // up as soon as it is live. A page Turbo fetches fresh carries no mark and
53
+ // fetches nothing more.
54
+
55
+ const SOURCE = "turbo-cable-stream-source"
56
+ const CACHED = "chats-cached"
57
+ const AWAY_FOR_MS = 10_000
58
+ const TOGETHER_MS = 250
59
+ const SPACED_BY_MS = 5_000
60
+
61
+ // Watch +element+'s stream sources (all of them, or those matching
62
+ // `sources:`, a selector) and the page, and call +catchUp+ whenever a
63
+ // broadcast may have been missed. Returns the function that stops watching.
64
+ //
65
+ // `stale:` says the element is known to be behind (a catch-up of its was
66
+ // given up: its page rendered it away, the request failed, Turbo never
67
+ // applied the answer). While it says so, the element also catches up when
68
+ // it connects, on its stream's first confirmation, and after every page
69
+ // render (`turbo:render`, `turbo:load`) — through the same coalescing and
70
+ // 5-second spacing as every other trigger, which also survives the element
71
+ // being carried from page to page.
72
+ export function recoverMissedBroadcasts(element, catchUp, { sources = SOURCE, stale = () => false } = {}) {
73
+ const recovery = new Recovery(element, catchUp, sources, stale)
74
+ recovery.start()
75
+ return () => recovery.stop()
76
+ }
77
+
78
+ // GET +url+ as a Turbo Stream and render it, as long as `when` says it is
79
+ // still wanted (its page wasn't rendered again meanwhile): asked when the
80
+ // answer arrives, and again by each of its streams at the moment Turbo
81
+ // applies it, a repaint later — a page visit or a frame reload in that gap
82
+ // would otherwise hand the old streams the new page.
83
+ //
84
+ // Resolves once Turbo has applied all of it, to true, or to false when
85
+ // nothing was (unwanted, cancelled, failed) — never before, however late
86
+ // Turbo gets to it: a caller that reads the page afterwards reads what the
87
+ // answer did. Bounded everywhere a request can stall:
88
+ // * a request that hasn't answered within REQUEST_WITHIN_MS is aborted
89
+ // (a fetch can hang across a network interruption);
90
+ // * the same request asked twice in the same instant is made once; asked
91
+ // again while it is on its way, it is made once more as soon as the
92
+ // first has its answer from the network (once for all who asked
93
+ // meanwhile), so every caller gets an answer built after it asked,
94
+ // within REQUEST_WITHIN_MS of the first; and an answer Turbo hasn't
95
+ // applied within APPLY_WITHIN_MS no longer stands in for a new one;
96
+ // * one unapplied answer per `key` (the URL, or what the caller names —
97
+ // the thread names itself): a newer answer for it cancels the older
98
+ // one's leftover streams, and so does cancelStreamsFor(key), which the
99
+ // thread calls as it disconnects. "Newer" is the caller's `order` (the
100
+ // thread numbers its catch-ups) when it gives one, else arrival: an
101
+ // older answer arriving after a newer one is waiting is dropped instead;
102
+ // * `applying` is called once, as the first stream of the answer is about
103
+ // to be applied, so a caller can tell answers older than it to stop
104
+ // (the thread's `when` turns false for them).
105
+ // Anything but a Turbo Stream (a sign-in page after the session expired, an
106
+ // error page) is ignored: the next catch-up tries again.
107
+ const requests = new Map()
108
+ const answers = new Map() // key → the answer still being applied
109
+ const REQUEST_WITHIN_MS = 15_000
110
+ const APPLY_WITHIN_MS = 2_000
111
+ // An answer Turbo hasn't applied this long after it joined the page never
112
+ // will be: it is cancelled (its callers settle false, and a thread keeps
113
+ // its stale mark, so it asks again on the next trigger).
114
+ const APPLIED_WITHIN_MS = 10_000
115
+
116
+ export function renderStreamFrom(url, { when = () => true, key = url, order = 0, applying = () => {} } = {}) {
117
+ watchPageRenders()
118
+
119
+ let resolve
120
+ const caller = { when, key, order, applying, answered: new Promise((done) => { resolve = done }) }
121
+ caller.resolve = resolve
122
+
123
+ const inFlight = requests.get(url)
124
+ if (inFlight?.sameInstant) {
125
+ // Asked in the same instant: the same answer will do.
126
+ inFlight.callers.push(caller)
127
+ } else if (inFlight) {
128
+ // Asked once it was on its way: its answer is a snapshot from before
129
+ // this was asked, and may miss what this wants. One more request, as
130
+ // soon as that one has its answer from the network (applied or not),
131
+ // for every caller that came meanwhile.
132
+ if (!inFlight.queued) {
133
+ inFlight.queued = []
134
+ inFlight.answered.then(() => send(url, inFlight.queued))
135
+ }
136
+ inFlight.queued.push(caller)
137
+ } else {
138
+ send(url, [caller])
139
+ }
140
+ return caller.answered
141
+ }
142
+
143
+ // A caller's `applying` hook, on its own: one that throws is reported and
144
+ // marks that caller failed (it resolves false), and never keeps the others,
145
+ // or the stream, from applying.
146
+ function runApplying(caller) {
147
+ try {
148
+ caller.applying()
149
+ } catch (error) {
150
+ caller.failed = true
151
+ console.error(error)
152
+ }
153
+ }
154
+
155
+ // One request for +callers+ (who asked for the same URL in the same
156
+ // instant, or while the previous one was on its way). Each keeps its own
157
+ // `when`: the answer is applied if any of them still wants it, through the
158
+ // options of those who do, and each resolves to its own outcome.
159
+ function send(url, callers) {
160
+ const abort = new AbortController()
161
+ const hung = setTimeout(() => abort.abort(), REQUEST_WITHIN_MS)
162
+ let networkDone
163
+ const entry = { callers, sameInstant: true, answered: new Promise((done) => { networkDone = done }) }
164
+ queueMicrotask(() => { entry.sameInstant = false })
165
+ const release = () => { if (requests.get(url) === entry) requests.delete(url) }
166
+ requests.set(url, entry)
167
+
168
+ fetch(url, { headers: { Accept: "text/vnd.turbo-stream.html" }, credentials: "same-origin", signal: abort.signal })
169
+ .then((response) => (isTurboStream(response) ? response.text() : ""))
170
+ .catch(() => "")
171
+ .then(async (html) => {
172
+ clearTimeout(hung)
173
+ networkDone()
174
+ entry.sameInstant = false
175
+
176
+ const wanted = []
177
+ for (const caller of entry.callers) {
178
+ if (html && await stillWanted(caller.when)) wanted.push(caller)
179
+ else caller.resolve(false)
180
+ }
181
+ if (!wanted.length) return
182
+
183
+ setTimeout(release, APPLY_WITHIN_MS)
184
+ const newest = wanted.reduce((a, b) => (b.order > a.order ? b : a))
185
+ const applied = await apply(html, () => wanted.some((caller) => caller.when()), newest.key, newest.order,
186
+ () => wanted.forEach(runApplying))
187
+ wanted.forEach((caller) => caller.resolve(applied && !caller.failed && caller.when()))
188
+ })
189
+ .catch(() => entry.callers.forEach((caller) => caller.resolve(false)))
190
+ .finally(() => {
191
+ clearTimeout(hung)
192
+ release()
193
+ })
194
+ }
195
+
196
+ // Drops what is left of +key+'s answer: streams Turbo hasn't applied yet
197
+ // never will be.
198
+ export function cancelStreamsFor(key) {
199
+ answers.get(key)?.cancel()
200
+ }
201
+
202
+ // --- Page renders ------------------------------------------------------------
203
+ //
204
+ // A Turbo visit carries a permanent element (the dock, its windows, their
205
+ // threads) to the next page by taking it off the page and putting it back
206
+ // once the new body is in. That is usually one task, but a host may render
207
+ // asynchronously (a `turbo:before-render` listener that animates, or uses a
208
+ // view transition), and the element is then off the page for as long as
209
+ // that takes. So "off the page" means "gone" only once page rendering is
210
+ // over, and afterRendering() resolves a task after the last render ends.
211
+ //
212
+ // Pairing. Turbo's events carry no identity, so each `turbo:render` ends
213
+ // the OLDEST render still on record: first in, first out. Turbo's own page
214
+ // renders never overlap (a visit waits for the previous render), so this is
215
+ // exact for them; a host whose own renders could end out of order is held
216
+ // to the same rule, with the bounds below as the safety net.
217
+ //
218
+ // Bounds. A render that hasn't ended after RENDER_WITHIN_MS stops holding
219
+ // things up, but stays on record, in its place, until its own end arrives
220
+ // (so the next `turbo:render` is still paired with it), or until
221
+ // FORGET_AFTER_MS, when it is let go for good: render records are bounded
222
+ // memory. Nothing about a stale window is kept here: what a render made a
223
+ // window give up is marked on the window itself (the thread's
224
+ // `data-chats-stale`, which travels with the permanent dock), and that
225
+ // window asks again whenever it is back — see `stale:` below.
226
+ const RENDER_WITHIN_MS = 10_000
227
+ const FORGET_AFTER_MS = 60_000
228
+ const renders = [] // oldest first: { overdue, bound, forget }
229
+ let idle = null // { finished, resolve } while a render holds things up
230
+ let watchingRenders = false
231
+
232
+ const holding = () => renders.some((render) => !render.overdue)
233
+
234
+ function watchPageRenders() {
235
+ if (watchingRenders) return
236
+ watchingRenders = true
237
+
238
+ document.addEventListener("turbo:before-render", () => {
239
+ if (!idle) {
240
+ let resolve
241
+ idle = { finished: new Promise((done) => { resolve = done }) }
242
+ idle.resolve = resolve
243
+ }
244
+ const render = { overdue: false }
245
+ render.bound = setTimeout(() => {
246
+ render.overdue = true
247
+ settleIfIdle()
248
+ }, RENDER_WITHIN_MS)
249
+ render.forget = setTimeout(() => {
250
+ const index = renders.indexOf(render)
251
+ if (index >= 0) renders.splice(index, 1)
252
+ settleIfIdle()
253
+ }, FORGET_AFTER_MS)
254
+ renders.push(render)
255
+ })
256
+
257
+ document.addEventListener("turbo:render", () => {
258
+ const render = renders.shift()
259
+ if (!render) return
260
+
261
+ clearTimeout(render.bound)
262
+ clearTimeout(render.forget)
263
+ settleIfIdle()
264
+ })
265
+ }
266
+
267
+ function settleIfIdle() {
268
+ if (holding() || !idle) return
269
+
270
+ const { resolve } = idle
271
+ idle = null
272
+ setTimeout(resolve, 0)
273
+ }
274
+
275
+ // Resolves once no page render is holding things up, a task after the last
276
+ // one ends (a render that starts within that task is waited for too).
277
+ export async function afterRendering() {
278
+ watchPageRenders()
279
+ do {
280
+ await (idle ? idle.finished : new Promise((resolve) => setTimeout(resolve, 0)))
281
+ } while (idle)
282
+ }
283
+
284
+ // Whether +when+ holds — or, if it doesn't while a page is rendering, once
285
+ // the render is over.
286
+ async function stillWanted(when) {
287
+ if (when()) return true
288
+ if (!idle) return false
289
+ await afterRendering()
290
+ return when()
291
+ }
292
+
293
+ // Every stream of an answer carries its mark (data-chats-answer), so the
294
+ // page-wide guard below wraps those and no others: a host's own stream,
295
+ // rendered at the same moment, is none of its business.
296
+ let answerCount = 0
297
+
298
+ function apply(html, when, key, order, applying) {
299
+ if (!window.Turbo?.renderStreamMessage) return Promise.resolve(false)
300
+ guardStreams()
301
+ if ((answers.get(key)?.order ?? -Infinity) > order) return Promise.resolve(false) // a newer one is waiting
302
+ cancelStreamsFor(key)
303
+
304
+ const mark = `${Date.now().toString(36)}-${++answerCount}`
305
+ return new Promise((resolve) => {
306
+ const answer = {
307
+ order,
308
+ applying,
309
+ when,
310
+ applied: false,
311
+ cancelled: false,
312
+ settled: false,
313
+ settle(result) {
314
+ if (this.settled) return
315
+ this.settled = true
316
+ clearTimeout(this.bound)
317
+ this.observer?.disconnect()
318
+ if (answers.get(key) === this) answers.delete(key)
319
+ marked.delete(mark)
320
+ resolve(result)
321
+ },
322
+ cancel() {
323
+ this.cancelled = true
324
+ streamsMarked(mark).forEach((stream) => stream.remove())
325
+ this.settle(false)
326
+ }
327
+ }
328
+ answers.set(key, answer)
329
+ marked.set(mark, answer)
330
+
331
+ window.Turbo.renderStreamMessage(html.replace(/<turbo-stream\b/g, `<turbo-stream data-chats-answer="${mark}"`))
332
+ const waited = streamsMarked(mark).length > 0 // an answer with no stream has nothing left to apply
333
+
334
+ // Each stream removes itself from <html> once Turbo has performed it
335
+ // (or skipped it). A page re-rendered meanwhile cancels the rest — asked
336
+ // a task later, when a permanent element Turbo is carrying across a
337
+ // visit is back on the page. (A re-render inside <body>, which this
338
+ // doesn't see, is the caller's to cancel: the thread does as it goes.)
339
+ const check = () => {
340
+ if (answer.cancelled) return
341
+ if (!streamsMarked(mark).length) return answer.settle(answer.applied || !waited)
342
+ afterRendering().then(() => { if (!answer.cancelled && !when()) answer.cancel() })
343
+ }
344
+ answer.observer = new MutationObserver(check)
345
+ answer.observer.observe(document.documentElement, { childList: true })
346
+ if (waited) answer.bound = setTimeout(() => answer.cancel(), APPLIED_WITHIN_MS)
347
+ check()
348
+ })
349
+ }
350
+
351
+ const marked = new Map() // mark → its answer
352
+ let guarding = false
353
+
354
+ function guardStreams() {
355
+ if (guarding) return
356
+ guarding = true
357
+
358
+ document.addEventListener("turbo:before-stream-render", (event) => {
359
+ const answer = marked.get(event.target?.getAttribute?.("data-chats-answer"))
360
+ const render = event.detail?.render
361
+ if (!answer || typeof render !== "function") return
362
+
363
+ event.detail.render = async (stream) => {
364
+ if (answer.cancelled || !(await stillWanted(answer.when)) || answer.cancelled) return
365
+ if (!answer.applied) {
366
+ try {
367
+ answer.applying()
368
+ } catch (error) {
369
+ console.error(error) // a caller's hook never stops the stream
370
+ }
371
+ }
372
+ answer.applied = true
373
+ try {
374
+ await render(stream)
375
+ } catch (error) {
376
+ console.error(error)
377
+ answer.cancel() // what is left of a broken answer never applies; its callers settle
378
+ }
379
+ }
380
+ })
381
+ }
382
+
383
+ function streamsMarked(mark) {
384
+ return Array.from(document.documentElement.querySelectorAll?.(`:scope > turbo-stream[data-chats-answer="${mark}"]`) || [])
385
+ }
386
+
387
+ function isTurboStream(response) {
388
+ return response.ok && (response.headers.get("Content-Type") || "").startsWith("text/vnd.turbo-stream.html")
389
+ }
390
+
391
+ // Two page-wide listeners, installed once:
392
+ //
393
+ // * A page refresh morphs every element to the server's HTML, and the
394
+ // server never renders `connected`: a morph would strip it from a stream
395
+ // source whose subscription is still live, and the attribute would stop
396
+ // telling the truth until the next reconnect. Keep it.
397
+ // * Mark the stream sources of a page going into Turbo's snapshot cache.
398
+ // Not a permanent element's: Turbo carries the live one along instead of
399
+ // restoring it, and it is never stale.
400
+ let watchingPage = false
401
+
402
+ function watchPage() {
403
+ if (watchingPage) return
404
+ watchingPage = true
405
+
406
+ document.addEventListener("turbo:before-morph-attribute", (event) => {
407
+ if (event.target.localName === SOURCE && event.detail?.attributeName === "connected") event.preventDefault()
408
+ })
409
+ document.addEventListener("turbo:before-cache", () => {
410
+ document.querySelectorAll(SOURCE).forEach((source) => {
411
+ if (!source.closest("[data-turbo-permanent]")) source.setAttribute(CACHED, "")
412
+ })
413
+ })
414
+ }
415
+
416
+ const lastCaughtUp = new WeakMap() // element → when it last caught up
417
+
418
+ class Recovery {
419
+ constructor(element, catchUp, sources, stale) {
420
+ this.element = element
421
+ this.catchUp = catchUp
422
+ this.selector = sources
423
+ this.stale = stale
424
+ this.confirmed = new WeakSet()
425
+ }
426
+
427
+ get lastCaughtUpAt() {
428
+ return lastCaughtUp.get(this.element) ?? -Infinity
429
+ }
430
+
431
+ set lastCaughtUpAt(at) {
432
+ lastCaughtUp.set(this.element, at)
433
+ }
434
+
435
+ start() {
436
+ watchPageRenders()
437
+ watchPage()
438
+ this.hiddenAt = document.visibilityState === "visible" ? null : Date.now()
439
+
440
+ this.observer = new MutationObserver((records) => this.sourcesChanged(records))
441
+ this.sources.forEach((source) => {
442
+ if (source.hasAttribute(CACHED)) {
443
+ source.removeAttribute(CACHED)
444
+ this.confirmed.add(source)
445
+ }
446
+ this.observer.observe(source, { attributes: true, attributeFilter: ["connected"] })
447
+ })
448
+
449
+ document.addEventListener("visibilitychange", this.visibilityChanged)
450
+ document.addEventListener("turbo:render", this.pageRendered)
451
+ document.addEventListener("turbo:load", this.pageRendered)
452
+ window.addEventListener("online", this.online)
453
+ window.addEventListener("pageshow", this.pageShown)
454
+ if (this.stale()) this.request()
455
+ }
456
+
457
+ stop() {
458
+ this.observer?.disconnect()
459
+ document.removeEventListener("visibilitychange", this.visibilityChanged)
460
+ document.removeEventListener("turbo:render", this.pageRendered)
461
+ document.removeEventListener("turbo:load", this.pageRendered)
462
+ window.removeEventListener("online", this.online)
463
+ window.removeEventListener("pageshow", this.pageShown)
464
+ clearTimeout(this.timer)
465
+ this.timer = null
466
+ }
467
+
468
+ get sources() {
469
+ return Array.from(this.element.querySelectorAll(this.selector))
470
+ }
471
+
472
+ // --- Triggers ---------------------------------------------------------------
473
+
474
+ sourcesChanged(records) {
475
+ records.forEach(({ target: source }) => {
476
+ if (!source.hasAttribute("connected")) return
477
+ if (this.confirmed.has(source) || this.stale()) this.request()
478
+ this.confirmed.add(source)
479
+ })
480
+ }
481
+
482
+ pageRendered = () => {
483
+ if (this.stale()) this.request()
484
+ }
485
+
486
+ visibilityChanged = () => {
487
+ if (document.visibilityState !== "visible") {
488
+ this.hiddenAt ??= Date.now()
489
+ return
490
+ }
491
+
492
+ const away = this.hiddenAt !== null && Date.now() - this.hiddenAt >= AWAY_FOR_MS
493
+ this.hiddenAt = null
494
+ if (away || this.pending) this.request()
495
+ }
496
+
497
+ online = () => {
498
+ this.checkSocket()
499
+ this.request()
500
+ }
501
+
502
+ pageShown = (event) => {
503
+ if (!event.persisted) return
504
+
505
+ this.checkSocket()
506
+ this.request()
507
+ }
508
+
509
+ // Action Cable checks its socket when the page becomes visible
510
+ // (ConnectionMonitor#visibilityDidChange): stale or closed, it reopens it
511
+ // at once instead of waiting out its reconnect backoff. Ask for the same
512
+ // check now, when there is a monitor to ask.
513
+ checkSocket() {
514
+ const monitor = this.sources[0]?.subscription?.consumer?.connection?.monitor
515
+ if (typeof monitor?.visibilityDidChange === "function") monitor.visibilityDidChange()
516
+ }
517
+
518
+ // --- Catching up ----------------------------------------------------------------
519
+
520
+ request() {
521
+ this.pending = true
522
+ if (this.timer) return
523
+
524
+ const wait = Math.max(TOGETHER_MS, this.lastCaughtUpAt + SPACED_BY_MS - Date.now())
525
+ this.timer = setTimeout(() => this.flush(), wait)
526
+ }
527
+
528
+ flush() {
529
+ this.timer = null
530
+ // Hidden (it waits to be seen), or a Turbo preview the real page is
531
+ // about to replace.
532
+ if (document.visibilityState !== "visible") return
533
+ if (document.documentElement.hasAttribute("data-turbo-preview")) return
534
+
535
+ this.pending = false
536
+ this.lastCaughtUpAt = Date.now()
537
+ this.catchUp()
538
+ }
539
+ }