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,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
+ }
@@ -0,0 +1,76 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { recoverMissedBroadcasts } from "chats/recovery"
3
+
4
+ // chats--refresh-inbox — the inbox is never left showing an old list.
5
+ //
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:
10
+ //
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.
20
+ //
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.
25
+ //
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
+ export default class extends Controller {
30
+ connect() {
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)
36
+ }
37
+
38
+ 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)
44
+ }
45
+
46
+ refresh() {
47
+ window.Turbo?.session?.refresh(document.baseURI, { method: "morph", scroll: "preserve" })
48
+ }
49
+
50
+ // --- A refresh that lands mid-visit -------------------------------------------
51
+ //
52
+ // Turbo marks <html aria-busy> for as long as a visit runs.
53
+
54
+ streamArrived = (event) => {
55
+ if (event.target.getAttribute?.("action") !== "refresh") return
56
+ if (document.documentElement.hasAttribute("aria-busy")) this.refreshWaiting = true
57
+ }
58
+
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 ---------------------------------------------------
72
+
73
+ keepWhatIsBeingTyped = (event) => {
74
+ if (event.detail?.attributeName === "value" && event.target === document.activeElement) event.preventDefault()
75
+ }
76
+ }