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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1037 -0
- data/README.md +621 -40
- data/app/assets/stylesheets/chats.css +906 -2
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +396 -86
- data/app/controllers/chats/messages_controller.rb +35 -13
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +230 -7
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1120 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +76 -0
- data/app/javascript/chats/split_controller.js +131 -0
- data/app/javascript/chats/thread_controller.js +322 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +61 -0
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
- data/app/views/chats/conversations/_thread.html.erb +147 -0
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +142 -15
- data/app/views/chats/conversations/pane.html.erb +14 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +18 -134
- data/app/views/chats/conversations/window.html.erb +26 -0
- data/app/views/chats/dock/_badge.html.erb +12 -0
- data/app/views/chats/dock/_dock.html.erb +139 -0
- data/app/views/chats/dock/_signal.html.erb +26 -0
- data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
- data/app/views/chats/dock/_window.html.erb +23 -0
- data/app/views/chats/dock/_window_header.html.erb +75 -0
- data/app/views/chats/dock/_window_shell.html.erb +17 -0
- data/app/views/chats/messages/_action.html.erb +15 -0
- data/app/views/chats/messages/_composer.html.erb +84 -56
- data/app/views/chats/messages/_message.html.erb +43 -15
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +10 -1
- data/config/locales/en.yml +40 -0
- data/config/locales/es.yml +40 -0
- data/config/routes.rb +8 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/docs/screenshots/composer-actions.png +0 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/docs/screenshots/inbox.png +0 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/docs/screenshots/system-note-action.png +0 -0
- data/docs/screenshots/thread-actions-phone.png +0 -0
- data/gemfiles/rails_7.1.gemfile +3 -0
- data/gemfiles/rails_7.2.gemfile +3 -0
- data/gemfiles/rails_8.1.gemfile +3 -0
- data/lib/chats/action.rb +85 -0
- data/lib/chats/broadcasts.rb +178 -17
- data/lib/chats/configuration.rb +115 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +363 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/macros.rb +33 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +122 -6
- data/lib/chats/models/conversation.rb +153 -19
- data/lib/chats/models/message.rb +292 -21
- data/lib/chats/models/participant.rb +67 -0
- data/lib/chats/models/reaction.rb +18 -0
- data/lib/chats/send_rate_limited.rb +30 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +123 -15
- data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
- data/lib/generators/chats/templates/initializer.rb +125 -14
- data/lib/generators/chats/upgrade_generator.rb +79 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- 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
|
+
}
|