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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +349 -0
- data/README.md +91 -8
- data/app/assets/stylesheets/chats.css +114 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +305 -63
- data/app/helpers/chats/engine_helper.rb +27 -4
- data/app/javascript/chats/dock_controller.js +54 -4
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/split_controller.js +97 -0
- data/app/javascript/chats/thread_controller.js +245 -54
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +20 -4
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_thread.html.erb +29 -54
- 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/index.html.erb +91 -15
- data/app/views/chats/conversations/pane.html.erb +12 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +10 -65
- data/app/views/chats/dock/_dock.html.erb +14 -5
- data/app/views/chats/messages/_message.html.erb +8 -6
- data/chats-0.3.2.gem +0 -0
- data/chats-0.4.0.gem +0 -0
- data/chats-0.4.1.gem +0 -0
- data/chats-0.4.2.gem +0 -0
- data/chats-0.5.0.gem +0 -0
- data/chats-0.5.1.gem +0 -0
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/config/routes.rb +3 -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/lib/chats/broadcasts.rb +102 -7
- data/lib/chats/configuration.rb +23 -0
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/conversation.rb +38 -0
- data/lib/chats/models/message.rb +98 -9
- data/lib/chats/models/reaction.rb +13 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +9 -0
- metadata +21 -1
|
@@ -1,86 +1,76 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { recoverMissedBroadcasts } from "chats/recovery"
|
|
2
3
|
|
|
3
|
-
// chats--refresh-inbox —
|
|
4
|
+
// chats--refresh-inbox — the inbox is never left showing an old list.
|
|
4
5
|
//
|
|
5
|
-
// The inbox updates
|
|
6
|
-
// (Chats::Broadcasts.refresh_inbox_of
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// forever, and the inbox silently sits at its last render until the user
|
|
10
|
-
// happens to navigate. This controller re-runs the SAME page refresh on the
|
|
11
|
-
// two moments we might have missed one — the socket reconnecting, and the
|
|
12
|
-
// tab becoming visible again after long enough that the socket was reaped.
|
|
6
|
+
// The inbox updates through Turbo 8 page refreshes on the viewer's inbox
|
|
7
|
+
// stream (Chats::Broadcasts.refresh_inbox_of): the page re-requests itself
|
|
8
|
+
// and morphs, scroll kept. This controller wraps that stream's
|
|
9
|
+
// <turbo-cable-stream-source> and covers the ways a refresh can be lost:
|
|
13
10
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
11
|
+
// * MISSED, because the socket was down when it was sent: chats/recovery
|
|
12
|
+
// runs the same page refresh when the stream reconnects, when the page
|
|
13
|
+
// comes back after 10 seconds hidden, when the network comes back, and
|
|
14
|
+
// when the browser restores the page from its back/forward cache.
|
|
15
|
+
// * DROPPED, because it arrived while a visit was in flight: Turbo ignores
|
|
16
|
+
// a refresh while another visit is running (Session#refresh checks
|
|
17
|
+
// `navigator.currentVisit`), so a message that lands while the previous
|
|
18
|
+
// refresh is being fetched would be lost with a healthy socket. One
|
|
19
|
+
// arriving then is run once that visit completes.
|
|
21
20
|
//
|
|
22
|
-
//
|
|
23
|
-
// the
|
|
21
|
+
// A refresh never rewrites the field the viewer is typing in: the morph
|
|
22
|
+
// leaves the focused field's value alone. The search itself survives a
|
|
23
|
+
// refresh because the inbox's search form keeps the query in the URL
|
|
24
|
+
// (`data-turbo-action="replace"`), which is what a refresh re-requests.
|
|
24
25
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// The catch-up IS a page refresh, on the same code path a broadcast takes:
|
|
27
|
+
// in Hotwire Native, Turbo hands it to the native adapter like any other.
|
|
28
|
+
// Registered as "chats--refresh-inbox" through the engine's importmap pin.
|
|
29
29
|
export default class extends Controller {
|
|
30
30
|
connect() {
|
|
31
|
-
this.
|
|
32
|
-
this.
|
|
33
|
-
document.addEventListener("
|
|
34
|
-
this.
|
|
31
|
+
this.stopRecovery = recoverMissedBroadcasts(this.element, () => this.refresh())
|
|
32
|
+
document.addEventListener("turbo:visit", this.visitStarted)
|
|
33
|
+
document.addEventListener("turbo:load", this.visitCompleted)
|
|
34
|
+
document.addEventListener("turbo:before-stream-render", this.streamArrived)
|
|
35
|
+
document.addEventListener("turbo:before-morph-attribute", this.keepWhatIsBeingTyped)
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
disconnect() {
|
|
38
|
-
|
|
39
|
-
this.
|
|
39
|
+
this.stopRecovery()
|
|
40
|
+
document.removeEventListener("turbo:visit", this.visitStarted)
|
|
41
|
+
document.removeEventListener("turbo:load", this.visitCompleted)
|
|
42
|
+
document.removeEventListener("turbo:before-stream-render", this.streamArrived)
|
|
43
|
+
document.removeEventListener("turbo:before-morph-attribute", this.keepWhatIsBeingTyped)
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// Hidden long enough that the socket may have been reaped → catch up
|
|
45
|
-
// on whatever the missed broadcasts would have refreshed.
|
|
46
|
-
if (this.hiddenAt && Date.now() - this.hiddenAt > REFRESH_AFTER_HIDDEN_MS) {
|
|
47
|
-
this.refresh()
|
|
48
|
-
}
|
|
49
|
-
this.hiddenAt = null
|
|
50
|
-
} else {
|
|
51
|
-
this.hiddenAt = Date.now()
|
|
52
|
-
}
|
|
46
|
+
refresh() {
|
|
47
|
+
window.Turbo?.session?.refresh(document.baseURI, { method: "morph", scroll: "preserve" })
|
|
53
48
|
}
|
|
54
49
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// that morphs the page can momentarily strip the attribute — harmless here:
|
|
59
|
-
// it only arms `streamDropped`, and the next genuine reconnect does one
|
|
60
|
-
// (cheap, idempotent) catch-up refresh.
|
|
61
|
-
watchStreamSource() {
|
|
62
|
-
const source = this.element.querySelector("turbo-cable-stream-source")
|
|
63
|
-
if (!source || typeof MutationObserver === "undefined") return
|
|
50
|
+
// --- A refresh that lands mid-visit -------------------------------------------
|
|
51
|
+
//
|
|
52
|
+
// Turbo marks <html aria-busy> for as long as a visit runs.
|
|
64
53
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
this.streamDropped = false
|
|
69
|
-
this.refresh()
|
|
70
|
-
} else if (!connected) {
|
|
71
|
-
this.streamDropped = true
|
|
72
|
-
}
|
|
73
|
-
})
|
|
74
|
-
this.sourceObserver.observe(source, { attributes: true, attributeFilter: ["connected"] })
|
|
54
|
+
streamArrived = (event) => {
|
|
55
|
+
if (event.target.getAttribute?.("action") !== "refresh") return
|
|
56
|
+
if (document.documentElement.hasAttribute("aria-busy")) this.refreshWaiting = true
|
|
75
57
|
}
|
|
76
58
|
|
|
77
|
-
refresh
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
59
|
+
// A visit that starts after the refresh arrived fetches what it was about.
|
|
60
|
+
visitStarted = () => {
|
|
61
|
+
this.refreshWaiting = false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
visitCompleted = () => {
|
|
65
|
+
if (!this.refreshWaiting) return
|
|
66
|
+
|
|
67
|
+
this.refreshWaiting = false
|
|
68
|
+
if (this.element.isConnected) this.refresh()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- Typing through a refresh ---------------------------------------------------
|
|
83
72
|
|
|
84
|
-
|
|
73
|
+
keepWhatIsBeingTyped = (event) => {
|
|
74
|
+
if (event.detail?.attributeName === "value" && event.target === document.activeElement) event.preventDefault()
|
|
85
75
|
}
|
|
86
76
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// chats--split — the two-pane messaging page (`config.inbox_layout = :split`).
|
|
4
|
+
//
|
|
5
|
+
// chats.css lays the page out on its own, with one media query: both panes
|
|
6
|
+
// from 700px wide, one below. What CSS can't decide is where a row's link
|
|
7
|
+
// goes, and that is all this does:
|
|
8
|
+
//
|
|
9
|
+
// * wide: the conversation frame is enabled, so a row opens its
|
|
10
|
+
// conversation beside the list, in one request for the pane alone, and
|
|
11
|
+
// the address bar moves to /messages/:id (the frame's
|
|
12
|
+
// data-turbo-action="advance");
|
|
13
|
+
// * narrow: the frame stays `disabled`, so the same link opens the
|
|
14
|
+
// conversation's page, which then fills the screen. A disabled frame is
|
|
15
|
+
// one Turbo never navigates, links naming it included. And no hover
|
|
16
|
+
// prefetch: the link names the frame, so Turbo would ask for the pane
|
|
17
|
+
// alone, and the page visit would then render that answer as the page.
|
|
18
|
+
//
|
|
19
|
+
// The server always renders the frame disabled, because a page visit is
|
|
20
|
+
// right at any width: it is what a click does before this connects, and
|
|
21
|
+
// without JavaScript. So the page's refreshes (Turbo 8 morphs, every live
|
|
22
|
+
// update of the list) would disable it again on a wide screen; the frame
|
|
23
|
+
// keeps what the screen says.
|
|
24
|
+
//
|
|
25
|
+
// When the pane opens a conversation, its row becomes the current one
|
|
26
|
+
// (aria-current="page", .chats-row--current) at once, exactly as the server
|
|
27
|
+
// renders /messages/:id, instead of when the list next refreshes. And the
|
|
28
|
+
// focus goes where the viewer is now, as LinkedIn's messaging page does:
|
|
29
|
+
// into the conversation's composer, ready to reply. Entering the pane, a
|
|
30
|
+
// screen reader names it (a section labelled by the conversation's title),
|
|
31
|
+
// then the field. On a touch screen the composer would raise the keyboard
|
|
32
|
+
// over a conversation not read yet, so the focus goes to the pane's title
|
|
33
|
+
// instead (tabindex="-1"), which is read out the same; and to the title
|
|
34
|
+
// too when there is nothing to write in (a locked conversation).
|
|
35
|
+
//
|
|
36
|
+
// Registered as "chats--split" through the engine's importmap pin.
|
|
37
|
+
const WIDE = "(min-width: 700px)" // keep in step with chats.css
|
|
38
|
+
const TOUCH = "(pointer: coarse)"
|
|
39
|
+
|
|
40
|
+
export default class extends Controller {
|
|
41
|
+
static targets = ["frame", "list"]
|
|
42
|
+
|
|
43
|
+
connect() {
|
|
44
|
+
this.media = window.matchMedia?.(WIDE)
|
|
45
|
+
this.media?.addEventListener?.("change", this.layout)
|
|
46
|
+
this.layout()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
disconnect() {
|
|
50
|
+
this.media?.removeEventListener?.("change", this.layout)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
layout = () => {
|
|
54
|
+
if (this.hasFrameTarget) this.frameTarget.toggleAttribute("disabled", !this.media?.matches)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// turbo:before-morph-attribute: a refresh leaves the frame as the screen has it.
|
|
58
|
+
keepEnabled(event) {
|
|
59
|
+
if (event.target === this.frameTarget && event.detail?.attributeName === "disabled") event.preventDefault()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// turbo:before-prefetch: nothing names a frame that can't take it.
|
|
63
|
+
prefetch(event) {
|
|
64
|
+
if (!this.hasFrameTarget || !this.frameTarget.hasAttribute("disabled")) return
|
|
65
|
+
if (event.target?.getAttribute?.("data-turbo-frame") === this.frameTarget.id) event.preventDefault()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// turbo:frame-load: the pane opened a conversation. Its row is current,
|
|
69
|
+
// and the viewer is taken to it.
|
|
70
|
+
opened(event) {
|
|
71
|
+
if (!this.hasFrameTarget || event.target !== this.frameTarget || !this.hasListTarget) return
|
|
72
|
+
const src = this.frameTarget.getAttribute("src")
|
|
73
|
+
if (!src) return
|
|
74
|
+
|
|
75
|
+
const open = new URL(src, document.baseURI).pathname
|
|
76
|
+
this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
|
|
77
|
+
const current = new URL(link.getAttribute("href"), document.baseURI).pathname === open
|
|
78
|
+
if (current) {
|
|
79
|
+
link.setAttribute("aria-current", "page")
|
|
80
|
+
} else {
|
|
81
|
+
link.removeAttribute("aria-current")
|
|
82
|
+
}
|
|
83
|
+
link.closest(".chats-row")?.classList.toggle("chats-row--current", current)
|
|
84
|
+
})
|
|
85
|
+
this.arrive()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
arrive() {
|
|
89
|
+
const pane = this.frameTarget.querySelector(".chats-split__pane")
|
|
90
|
+
if (!pane) return
|
|
91
|
+
|
|
92
|
+
const touch = Boolean(window.matchMedia?.(TOUCH)?.matches)
|
|
93
|
+
const composer = touch ? null : pane.querySelector(".chats-composer__input")
|
|
94
|
+
const title = pane.querySelector(`[id="${pane.getAttribute("aria-labelledby")}"]`)
|
|
95
|
+
;(composer || title)?.focus({ preventScroll: true })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { afterRendering, cancelStreamsFor, recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
|
|
2
3
|
|
|
3
|
-
// Stale-thread
|
|
4
|
+
// Stale-thread catch-up + DOM budget (both patterns from Basecamp's
|
|
4
5
|
// Campfire, https://github.com/basecamp/once-campfire — its
|
|
5
6
|
// refresh_room_controller and message_paginator):
|
|
6
|
-
// *
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// * catch up on what the stream missed whenever chats/recovery says it
|
|
8
|
+
// may have (a reconnect, a return after 10 seconds hidden, the network
|
|
9
|
+
// back, a page restored from the back/forward cache or a Turbo
|
|
10
|
+
// snapshot): GET #refresh?since= appends, replaces and removes exactly
|
|
11
|
+
// what changed, so the composer, its draft and the scroll position are
|
|
12
|
+
// never touched;
|
|
9
13
|
// * cap rendered bubbles so day-long sessions in a busy group don't
|
|
10
14
|
// grow the DOM unboundedly (trimmed history stays reachable — the
|
|
11
15
|
// pagination anchor is rebuilt to re-fetch it on scroll-up).
|
|
12
|
-
const REFRESH_AFTER_HIDDEN_MS = 60_000
|
|
13
16
|
// Long-press tuning: Telegram-ish. The move tolerance keeps a scroll
|
|
14
17
|
// gesture from ever reading as a press.
|
|
15
18
|
const LONG_PRESS_MS = 450
|
|
@@ -19,6 +22,11 @@ const PRESS_MOVE_TOLERANCE_PX = 12
|
|
|
19
22
|
// last few pixels home. Keep this a hair ABOVE the CSS transform duration.
|
|
20
23
|
const POPUP_TRANSITION_MS = 280
|
|
21
24
|
const MAX_RENDERED_MESSAGES = 300
|
|
25
|
+
// Keep in step with ConversationsController::CURSOR_IDS_LIMIT.
|
|
26
|
+
const CURSOR_IDS = 200
|
|
27
|
+
|
|
28
|
+
// A name for one render of a thread.
|
|
29
|
+
const newRender = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
|
22
30
|
const TRIM_LEEWAY = 20
|
|
23
31
|
|
|
24
32
|
// chats--thread: everything live about an open conversation.
|
|
@@ -88,6 +96,10 @@ export default class extends Controller {
|
|
|
88
96
|
|
|
89
97
|
connect() {
|
|
90
98
|
this.registerTypingStreamAction()
|
|
99
|
+
this.registerPruneStreamAction()
|
|
100
|
+
this.registerCaughtUpStreamAction()
|
|
101
|
+
this.element.dataset.chatsRender ||= newRender()
|
|
102
|
+
this.caughtUp({ detail: this.storedCursors() || this.cursorsOfRender() })
|
|
91
103
|
// The dock's engagement gate: the window frame around a dock thread.
|
|
92
104
|
// No gate (the thread page) means always engaged.
|
|
93
105
|
this.gate = this.element.closest("[data-chats-active]")
|
|
@@ -124,7 +136,9 @@ export default class extends Controller {
|
|
|
124
136
|
event.stopPropagation()
|
|
125
137
|
}
|
|
126
138
|
this.element.addEventListener("click", this.suppressClickCapture, true)
|
|
127
|
-
this.
|
|
139
|
+
this.element.addEventListener("chats:prune", this.prune)
|
|
140
|
+
this.element.addEventListener("chats:caught-up", this.caughtUp)
|
|
141
|
+
this.stopRecovery = recoverMissedBroadcasts(this.element, () => this.refreshThread(), { stale: () => this.stale })
|
|
128
142
|
this.watchScreenPresence()
|
|
129
143
|
}
|
|
130
144
|
|
|
@@ -134,7 +148,17 @@ export default class extends Controller {
|
|
|
134
148
|
}
|
|
135
149
|
document.removeEventListener("visibilitychange", this.visibilityChanged)
|
|
136
150
|
clearTimeout(this.readTimer)
|
|
137
|
-
this.
|
|
151
|
+
this.stopRecovery?.()
|
|
152
|
+
// What this render asked for is not wanted any more — once it is really
|
|
153
|
+
// gone. A Turbo visit carries the dock from page to page by taking it
|
|
154
|
+
// off the page and putting it back once the new body is in: a window's
|
|
155
|
+
// thread is away for the length of that render and keeps everything,
|
|
156
|
+
// its answer on the way included. Still off the page once the render is
|
|
157
|
+
// over is a thread that went away (another page, a window reloaded or
|
|
158
|
+
// closed).
|
|
159
|
+
afterRendering().then(() => { if (!this.element.isConnected) cancelStreamsFor(this.element) })
|
|
160
|
+
this.element.removeEventListener("chats:prune", this.prune)
|
|
161
|
+
this.element.removeEventListener("chats:caught-up", this.caughtUp)
|
|
138
162
|
this.presenceObserver?.disconnect()
|
|
139
163
|
this.gate?.removeEventListener("chats:activated", this.flushPendingRead)
|
|
140
164
|
this.element.removeEventListener("click", this.suppressClickCapture, true)
|
|
@@ -149,7 +173,7 @@ export default class extends Controller {
|
|
|
149
173
|
// --- Bubbles ---------------------------------------------------------------
|
|
150
174
|
|
|
151
175
|
messageTargetConnected(element) {
|
|
152
|
-
this.
|
|
176
|
+
this.trackLegacyCursor(element)
|
|
153
177
|
this.classify(element)
|
|
154
178
|
if (this.booting) return
|
|
155
179
|
// Live appends can land out of order (multi-worker hosts broadcast from
|
|
@@ -375,20 +399,12 @@ export default class extends Controller {
|
|
|
375
399
|
this.readTimer = setTimeout(() => this.postRead(), 700)
|
|
376
400
|
}
|
|
377
401
|
|
|
402
|
+
// A read that waited for the tab to be seen happens when it is. (What the
|
|
403
|
+
// tab missed while hidden is chats/recovery's to fetch.)
|
|
378
404
|
visibilityChanged = () => {
|
|
379
|
-
if (document.visibilityState === "visible") {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (this.hiddenAt && Date.now() - this.hiddenAt > REFRESH_AFTER_HIDDEN_MS) {
|
|
383
|
-
this.refreshThread()
|
|
384
|
-
}
|
|
385
|
-
this.hiddenAt = null
|
|
386
|
-
if (this.pendingRead) {
|
|
387
|
-
this.pendingRead = false
|
|
388
|
-
this.queueRead()
|
|
389
|
-
}
|
|
390
|
-
} else {
|
|
391
|
-
this.hiddenAt = Date.now()
|
|
405
|
+
if (document.visibilityState === "visible" && this.pendingRead) {
|
|
406
|
+
this.pendingRead = false
|
|
407
|
+
this.queueRead()
|
|
392
408
|
}
|
|
393
409
|
}
|
|
394
410
|
|
|
@@ -511,45 +527,220 @@ export default class extends Controller {
|
|
|
511
527
|
|
|
512
528
|
// --- Stale-thread refresh (catch up after sleep/disconnect) --------------------
|
|
513
529
|
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
530
|
+
// Two cursors, because "new" and "changed" are different questions: the
|
|
531
|
+
// newest creation (`after`: anything newer is appended) and the newest
|
|
532
|
+
// change (`changed`: anything changed since is replaced). One can't
|
|
533
|
+
// answer both: a message created at 10:15 and missed, while a bubble on
|
|
534
|
+
// screen was edited at 10:20, is older than the newest change. Each is a
|
|
535
|
+
// moment to the microsecond the database keeps (an integer below 2^53,
|
|
536
|
+
// exact as a Number).
|
|
537
|
+
//
|
|
538
|
+
// They are RECONCILED moments: how far the thread knows it has
|
|
539
|
+
// everything. A complete render sets them (the page, a window, a history
|
|
540
|
+
// re-render: the server sends those with it), and so does a catch-up
|
|
541
|
+
// that arrived (the chats_caught_up action); a live bubble never moves
|
|
542
|
+
// them, because it can arrive past one that was missed (a message missed
|
|
543
|
+
// at 10:15, then one live at 10:20: a cursor at 10:20 would skip 10:15
|
|
544
|
+
// for good). What arrived live is named instead, so it isn't sent again:
|
|
545
|
+
// every bubble on screen created since `after`, and every one whose
|
|
546
|
+
// version is exactly the `changed` moment. They live on the element
|
|
547
|
+
// (data-chats-reconciled): the dock moves its windows, and a Turbo
|
|
548
|
+
// snapshot is restored, without losing them.
|
|
549
|
+
cursorsOfRender() {
|
|
550
|
+
const created = this.messageTargets.map((message) => Number(message.dataset.createdAtUs || 0))
|
|
551
|
+
const updated = this.messageTargets.map((message) => Number(message.dataset.updatedAtUs || 0))
|
|
552
|
+
const changed = Math.max(0, ...updated)
|
|
553
|
+
return {
|
|
554
|
+
after: Math.max(0, ...created),
|
|
555
|
+
changed,
|
|
556
|
+
changedIds: this.messageTargets.filter((_, index) => updated[index] === changed).map((message) => message.dataset.messageId)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// `data-chats-stale`: this thread is known to be behind. On the element,
|
|
561
|
+
// not the controller, because the element is what the dock carries from
|
|
562
|
+
// page to page (and what a long page render keeps away): the mark can't
|
|
563
|
+
// be forgotten while the thread exists, and goes when it does.
|
|
564
|
+
get stale() {
|
|
565
|
+
return "chatsStale" in this.element.dataset
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
set stale(behind) {
|
|
569
|
+
if (behind) {
|
|
570
|
+
this.element.dataset.chatsStale = ""
|
|
571
|
+
} else {
|
|
572
|
+
delete this.element.dataset.chatsStale
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Every catch-up is numbered on the element (data-chats-catch-up), so the
|
|
577
|
+
// count survives the dock carrying the thread from page to page.
|
|
578
|
+
startCatchUp() {
|
|
579
|
+
const attempt = this.latestCatchUp + 1
|
|
580
|
+
this.element.dataset.chatsCatchUp = String(attempt)
|
|
581
|
+
return attempt
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
get latestCatchUp() {
|
|
585
|
+
return Number(this.element.dataset.chatsCatchUp || 0)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// The newest catch-up whose answer has started applying
|
|
589
|
+
// (data-chats-applied): nothing older may apply after it.
|
|
590
|
+
get latestApplied() {
|
|
591
|
+
return Number(this.element.dataset.chatsApplied || 0)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// An answer carries the render it was asked from (`render`): one for an
|
|
595
|
+
// earlier render of this conversation (the page or the window rendered it
|
|
596
|
+
// again while the answer was on its way) would move the new render's
|
|
597
|
+
// cursors to the old one's, and is ignored.
|
|
598
|
+
caughtUp = (event) => {
|
|
599
|
+
const { render, ...cursors } = event.detail
|
|
600
|
+
if (render && render !== this.element.dataset.chatsRender) return
|
|
601
|
+
|
|
602
|
+
this.reconciled = cursors
|
|
603
|
+
this.element.dataset.chatsReconciled = JSON.stringify(cursors)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
storedCursors() {
|
|
607
|
+
try {
|
|
608
|
+
return JSON.parse(this.element.dataset.chatsReconciled || "null")
|
|
609
|
+
} catch {
|
|
610
|
+
return null
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Bubbles ejected before they carried the cursors above: the one
|
|
615
|
+
// millisecond cursor they had, which moves with every bubble.
|
|
616
|
+
trackLegacyCursor(element) {
|
|
617
|
+
const legacy = Number(element.dataset.updatedAtMs || 0)
|
|
618
|
+
if (legacy > (this.legacyCursor || 0)) this.legacyCursor = legacy
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// What the stream missed: messages created after the reconciled moment
|
|
622
|
+
// and not on screen (appended), ones on screen changed since (replaced:
|
|
623
|
+
// edits, tombstones, reactions), the read state, and — when messages are
|
|
624
|
+
// hard-deleted — which rendered bubbles still exist. An empty thread asks
|
|
625
|
+
// from the epoch: its first message may be the one it missed. `from` is
|
|
626
|
+
// the oldest rendered bubble, so the server can name every bubble that
|
|
627
|
+
// should be on screen. A thread more than a page behind, or naming more
|
|
628
|
+
// than CURSOR_IDS bubbles, gets its history re-rendered at the latest
|
|
629
|
+
// page instead (`history`: this thread's scroller has the id that takes
|
|
630
|
+
// it; one ejected before it did gets a page refresh); it then opens at
|
|
631
|
+
// the bottom, as a fresh thread does, and asks once more from there, for
|
|
632
|
+
// anything that arrived live while the history was being re-rendered.
|
|
633
|
+
refreshThread({ followUp = true } = {}) {
|
|
634
|
+
if (!this.refreshUrlValue) return
|
|
635
|
+
|
|
636
|
+
// Behind until an answer is applied: a catch-up given up for any reason
|
|
637
|
+
// (the page rendered the thread away, the request failed, Turbo never
|
|
638
|
+
// applied it) leaves the mark, and chats/recovery asks again on connect,
|
|
639
|
+
// on the stream's confirmation and after every page render.
|
|
640
|
+
this.stale = true
|
|
641
|
+
const attempt = this.startCatchUp()
|
|
642
|
+
const params = new URLSearchParams()
|
|
643
|
+
if (this.legacyCursor && !this.messageTargets.some((message) => message.dataset.createdAtUs)) {
|
|
644
|
+
params.set("since", this.legacyCursor)
|
|
645
|
+
} else {
|
|
646
|
+
const { after = 0, changed = 0, changedIds = [] } = this.reconciled || {}
|
|
647
|
+
const afterIds = this.messageTargets
|
|
648
|
+
.filter((message) => Number(message.dataset.createdAtUs || 0) >= after)
|
|
649
|
+
.map((message) => message.dataset.messageId)
|
|
650
|
+
const seenChanged = new Set(changedIds)
|
|
651
|
+
this.messageTargets.forEach((message) => {
|
|
652
|
+
if (Number(message.dataset.updatedAtUs || 0) === changed) seenChanged.add(message.dataset.messageId)
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
params.set("after", after)
|
|
656
|
+
params.set("changed", changed)
|
|
657
|
+
if (afterIds.length > CURSOR_IDS || seenChanged.size > CURSOR_IDS) {
|
|
658
|
+
params.set("full", "1")
|
|
659
|
+
} else {
|
|
660
|
+
if (afterIds.length) params.set("after_ids", afterIds.join(","))
|
|
661
|
+
if (seenChanged.size) params.set("changed_ids", [...seenChanged].join(","))
|
|
538
662
|
}
|
|
663
|
+
}
|
|
664
|
+
const oldest = this.messageTargets[0]
|
|
665
|
+
if (oldest?.dataset.createdAtUs) params.set("from", oldest.dataset.createdAtUs)
|
|
666
|
+
if (this.hasScrollerTarget && this.scrollerTarget.id) params.set("history", "1")
|
|
667
|
+
const last = this.messageTargets[this.messageTargets.length - 1]
|
|
668
|
+
|
|
669
|
+
// Bound to this render of the thread (data-chats-render, renewed by
|
|
670
|
+
// every complete render): an answer that arrives after the page or the
|
|
671
|
+
// window rendered the conversation again is dropped unrendered.
|
|
672
|
+
const render = this.element.dataset.chatsRender
|
|
673
|
+
params.set("render", render)
|
|
674
|
+
// …and to being no older than the newest catch-up applied: an answer
|
|
675
|
+
// that arrives (or reaches a stream) after a newer one has applied would
|
|
676
|
+
// put older bubbles, read state and cursors over the newer ones.
|
|
677
|
+
const current = () => this.element.isConnected && this.element.dataset.chatsRender === render &&
|
|
678
|
+
attempt >= this.latestApplied
|
|
679
|
+
|
|
680
|
+
// Resolves once Turbo has applied the answer, so `last` is gone by then
|
|
681
|
+
// if the history was re-rendered.
|
|
682
|
+
const applying = () => { if (attempt > this.latestApplied) this.element.dataset.chatsApplied = String(attempt) }
|
|
683
|
+
return renderStreamFrom(`${this.refreshUrlValue}?${params}`, {
|
|
684
|
+
when: current, key: this.element, order: attempt, applying
|
|
685
|
+
}).then((rendered) => {
|
|
686
|
+
if (!rendered) return
|
|
687
|
+
applying() // (an answer with no stream to apply has applied all the same)
|
|
688
|
+
// Only the newest catch-up's answer says the thread is up to date: an
|
|
689
|
+
// older one that applies after a newer one was asked (and maybe
|
|
690
|
+
// failed) still renders what it brought, and leaves the mark.
|
|
691
|
+
if (attempt === this.latestCatchUp) this.stale = false
|
|
692
|
+
if (!last || last.isConnected) return
|
|
693
|
+
|
|
694
|
+
this.element.dataset.chatsRender = newRender() // the re-rendered history is a new render
|
|
695
|
+
this.scrollToBottom()
|
|
696
|
+
if (followUp) return this.refreshThread({ followUp: false })
|
|
697
|
+
})
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// A hard delete the stream missed: the bubble is gone from the server, so
|
|
701
|
+
// it goes from here. Only bubbles created before `until`, which the server
|
|
702
|
+
// sets a little before it listed what exists: a message whose insert had
|
|
703
|
+
// not committed yet when it looked, and which then arrived live, is new,
|
|
704
|
+
// not deleted.
|
|
705
|
+
prune = (event) => {
|
|
706
|
+
const present = new Set(event.detail.ids)
|
|
707
|
+
this.messageTargets.forEach((message) => {
|
|
708
|
+
if (Number(message.dataset.createdAtUs || Infinity) > event.detail.until) return
|
|
709
|
+
if (!present.has(message.id)) message.remove()
|
|
539
710
|
})
|
|
540
|
-
this.sourceObserver.observe(source, { attributes: true, attributeFilter: ["connected"] })
|
|
541
711
|
}
|
|
542
712
|
|
|
543
|
-
|
|
544
|
-
|
|
713
|
+
// The cursors a catch-up (or a history re-render) leaves the thread at.
|
|
714
|
+
registerCaughtUpStreamAction() {
|
|
715
|
+
const Turbo = window.Turbo
|
|
716
|
+
if (!Turbo || Turbo.StreamActions.chats_caught_up) return
|
|
717
|
+
|
|
718
|
+
Turbo.StreamActions.chats_caught_up = function () {
|
|
719
|
+
const detail = {
|
|
720
|
+
render: this.getAttribute("data-render") || null,
|
|
721
|
+
after: Number(this.getAttribute("data-after") || 0),
|
|
722
|
+
changed: Number(this.getAttribute("data-changed") || 0),
|
|
723
|
+
changedIds: (this.getAttribute("data-changed-ids") || "").split(",").filter(Boolean)
|
|
724
|
+
}
|
|
725
|
+
this.targetElements.forEach((target) => {
|
|
726
|
+
target.dispatchEvent(new CustomEvent("chats:caught-up", { bubbles: true, detail }))
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
registerPruneStreamAction() {
|
|
732
|
+
const Turbo = window.Turbo
|
|
733
|
+
if (!Turbo || Turbo.StreamActions.chats_prune) return
|
|
545
734
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
735
|
+
Turbo.StreamActions.chats_prune = function () {
|
|
736
|
+
const detail = {
|
|
737
|
+
ids: (this.getAttribute("data-ids") || "").split(" ").filter(Boolean),
|
|
738
|
+
until: Number(this.getAttribute("data-until") || 0)
|
|
739
|
+
}
|
|
740
|
+
this.targetElements.forEach((target) => {
|
|
741
|
+
target.dispatchEvent(new CustomEvent("chats:prune", { bubbles: true, detail }))
|
|
551
742
|
})
|
|
552
|
-
|
|
743
|
+
}
|
|
553
744
|
}
|
|
554
745
|
|
|
555
746
|
// --- DOM budget -----------------------------------------------------------------
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
|
|
3
|
+
|
|
4
|
+
// chats--unread-badge — the unread badge (`chats_unread_badge`) never sits
|
|
5
|
+
// on an old count.
|
|
6
|
+
//
|
|
7
|
+
// The badge updates live on the viewer's badge stream. When that stream may
|
|
8
|
+
// have missed an update (chats/recovery: a reconnect, a return after 10
|
|
9
|
+
// seconds hidden, the network back, a restored page), this asks GET /badge
|
|
10
|
+
// for every unread badge on the page, the dock's included, and renders
|
|
11
|
+
// them. Nothing is fetched while the stream is healthy.
|
|
12
|
+
//
|
|
13
|
+
// Registered as "chats--unread-badge" through the engine's importmap pin.
|
|
14
|
+
export default class extends Controller {
|
|
15
|
+
static values = { url: String }
|
|
16
|
+
|
|
17
|
+
connect() {
|
|
18
|
+
this.stopRecovery = recoverMissedBroadcasts(this.element, () => renderStreamFrom(this.urlValue))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnect() {
|
|
22
|
+
this.stopRecovery()
|
|
23
|
+
}
|
|
24
|
+
}
|