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,1120 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
|
|
3
|
+
|
|
4
|
+
// chats--dock: the messaging dock (`chats_dock`) — LinkedIn / Steam style
|
|
5
|
+
// floating conversation windows in the bottom-right corner.
|
|
6
|
+
//
|
|
7
|
+
// The server renders everything: the panel (a <details>, so it opens with
|
|
8
|
+
// no JavaScript), its recent list (a lazy turbo frame), every window's
|
|
9
|
+
// thread (a turbo frame loading `?window=1`), and the phone layout (one
|
|
10
|
+
// media query in chats.css). This controller arranges and remembers:
|
|
11
|
+
//
|
|
12
|
+
// * windows open from a row or any `chats_dock_opener` link, from
|
|
13
|
+
// `chat_button_to …, dock: true` (a Turbo Stream appends
|
|
14
|
+
// the frame; an open one is focused, never reloaded), or
|
|
15
|
+
// from a live message. Never more than fit beside the
|
|
16
|
+
// panel (min(windows, floor((vw − 312) / 344)): 2 at
|
|
17
|
+
// 1280px, 1 at 768px); room is made by closing the least
|
|
18
|
+
// recently used window that doesn't have the focus, and
|
|
19
|
+
// its unsent text is kept. Never one for a conversation
|
|
20
|
+
// the page itself renders: the streams address the
|
|
21
|
+
// thread's DOM ids, which must exist once.
|
|
22
|
+
// * reading a window marks messages read only when it is expanded
|
|
23
|
+
// AND the viewer is engaged with it (`data-chats-active`,
|
|
24
|
+
// which the thread controller obeys). One the viewer
|
|
25
|
+
// opened is engaged from the start; one that popped open,
|
|
26
|
+
// or came back after a reload, once they focus, press or
|
|
27
|
+
// scroll in it.
|
|
28
|
+
// * live the viewer's `[messager, :chats_dock]` stream replaces
|
|
29
|
+
// the signal template; when it connects, the recent list
|
|
30
|
+
// reloads and, for a message the viewer should hear about,
|
|
31
|
+
// its window pops open (in dock mode only, never stealing
|
|
32
|
+
// focus, never for one closed in the last five minutes,
|
|
33
|
+
// never over a window in use) and is announced politely.
|
|
34
|
+
// * catching up what the dock's stream missed (chats/recovery: a
|
|
35
|
+
// reconnect, a return after 10 seconds hidden, the
|
|
36
|
+
// network back, a restored page) reloads the recent list,
|
|
37
|
+
// morphed in place so its scroll and search stay, and
|
|
38
|
+
// fetches the badges again. Each open window's thread
|
|
39
|
+
// catches up by itself. A missed message never pops a
|
|
40
|
+
// window open late: the badge and the list say it.
|
|
41
|
+
// * memory per viewer in localStorage (`chats:dock:v1:<key>`):
|
|
42
|
+
// which windows, minimized or not, recently closed ones,
|
|
43
|
+
// and the panel; drafts under `chats:draft:<key>:<id>`.
|
|
44
|
+
// Every access is guarded: no storage, no memory, no harm.
|
|
45
|
+
// * hiding on any page with [data-chats-dock-hide] (the engine's
|
|
46
|
+
// inbox and thread), since a permanent element is never
|
|
47
|
+
// re-rendered by the server; and no window for a
|
|
48
|
+
// conversation the page renders, after a visit or after a
|
|
49
|
+
// frame of the page loads one
|
|
50
|
+
// * keyboard Escape unwinds innermost first: the thread's popup or
|
|
51
|
+
// photo, an edit, the window menu, then the window
|
|
52
|
+
// (minimized), the panel (closed); on a phone, the window
|
|
53
|
+
// back to the list, then the list closed
|
|
54
|
+
//
|
|
55
|
+
// The element is data-turbo-permanent: Turbo Drive carries it, windows and
|
|
56
|
+
// all, from page to page, and may detach and reattach it, so connect()
|
|
57
|
+
// keeps whatever windows are already there.
|
|
58
|
+
//
|
|
59
|
+
// Registered automatically as "chats--dock" through the engine's importmap
|
|
60
|
+
// pin (see config/importmap.rb and Chats::Engine).
|
|
61
|
+
|
|
62
|
+
const SHEET = "(max-width: 699.98px), (max-height: 499.98px)" // keep in step with chats.css
|
|
63
|
+
const MINIMIZED = "chats-window--minimized"
|
|
64
|
+
const UNREAD = "chats-window--unread"
|
|
65
|
+
const FRONT = "chats-window--front"
|
|
66
|
+
const ENTERING = "chats-window--entering"
|
|
67
|
+
const POPPED = "chats-window--popped"
|
|
68
|
+
const SQUEEZED = "chats-window--squeezed" // folded to make room: narrow, compact header
|
|
69
|
+
const NO_REPOP_MS = 5 * 60_000
|
|
70
|
+
const ANNOUNCE_EVERY_MS = 30_000
|
|
71
|
+
const DRAFT_TTL_MS = 7 * 24 * 60 * 60_000
|
|
72
|
+
const DRAFT_DELAY_MS = 500
|
|
73
|
+
const LIST_DELAY_MS = 300
|
|
74
|
+
|
|
75
|
+
export default class extends Controller {
|
|
76
|
+
static targets = [
|
|
77
|
+
"windows", "window", "panel", "list", "badge", "signal", "announcer", "loadingShell", "failedShell", "composer"
|
|
78
|
+
]
|
|
79
|
+
static values = {
|
|
80
|
+
windows: { type: Number, default: 3 },
|
|
81
|
+
messager: String,
|
|
82
|
+
everywhere: Boolean,
|
|
83
|
+
windowUrl: String,
|
|
84
|
+
// dom_id's record key for a conversation, from the server: inside the
|
|
85
|
+
// isolated engine it is "conversation", not "chats_conversation".
|
|
86
|
+
recordKey: { type: String, default: "conversation" },
|
|
87
|
+
announcement: String,
|
|
88
|
+
windowUnread: String,
|
|
89
|
+
badgeUrl: String
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Stimulus runs the target callbacks (windowTargetConnected…) BEFORE
|
|
93
|
+
// connect(), so everything they touch is set up here.
|
|
94
|
+
initialize() {
|
|
95
|
+
this.openers = new WeakMap()
|
|
96
|
+
this.announced = {}
|
|
97
|
+
this.inerted = []
|
|
98
|
+
this.closed = this.read(this.storageKey)?.closed || {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
connect() {
|
|
102
|
+
this.media = window.matchMedia(SHEET)
|
|
103
|
+
this.media.addEventListener?.("change", this.layout)
|
|
104
|
+
window.addEventListener("resize", this.resized)
|
|
105
|
+
window.visualViewport?.addEventListener("resize", this.fitViewport)
|
|
106
|
+
window.visualViewport?.addEventListener("scroll", this.fitViewport)
|
|
107
|
+
|
|
108
|
+
// A window always names its viewer. One that doesn't match never stays:
|
|
109
|
+
// nothing of one account may surface in another's page.
|
|
110
|
+
this.windowTargets.forEach((frame) => {
|
|
111
|
+
if (frame.dataset.chatsMessager !== this.messagerValue) this.dropWindow(frame, { keepDraft: false })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
this.layout()
|
|
115
|
+
this.restore()
|
|
116
|
+
this.pageChanged()
|
|
117
|
+
|
|
118
|
+
// Its own stream only: each window's thread watches its own.
|
|
119
|
+
this.stopRecovery = recoverMissedBroadcasts(this.element, () => this.catchUp(),
|
|
120
|
+
{ sources: ":scope > turbo-cable-stream-source" })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
disconnect() {
|
|
124
|
+
this.media?.removeEventListener?.("change", this.layout)
|
|
125
|
+
window.removeEventListener("resize", this.resized)
|
|
126
|
+
window.visualViewport?.removeEventListener("resize", this.fitViewport)
|
|
127
|
+
window.visualViewport?.removeEventListener("scroll", this.fitViewport)
|
|
128
|
+
this.releaseSheet()
|
|
129
|
+
this.stopRecovery?.()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Opening ---------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
// Any [data-chats-dock-conversation-id] link (the rows, a host's own) and
|
|
135
|
+
// any [data-chats-dock-toggle] (a host's Messages link), on the whole
|
|
136
|
+
// page. They are real links: a click that means "somewhere else" (new
|
|
137
|
+
// tab, new window) is left alone, and so is everything while the dock is
|
|
138
|
+
// hidden.
|
|
139
|
+
delegate(event) {
|
|
140
|
+
if (event.defaultPrevented || event.button > 0 || this.element.hidden) return
|
|
141
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
142
|
+
|
|
143
|
+
const opener = event.target.closest("[data-chats-dock-conversation-id]")
|
|
144
|
+
if (opener) {
|
|
145
|
+
event.preventDefault()
|
|
146
|
+
const id = String(opener.dataset.chatsDockConversationId)
|
|
147
|
+
// No room even with every window in use folded: the link does what a
|
|
148
|
+
// link does, and opens the conversation's page.
|
|
149
|
+
if (this.openWindow(id, { focus: true, opener }) === false) this.visit(opener.href || this.threadUrl(id))
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const toggle = event.target.closest("[data-chats-dock-toggle]")
|
|
154
|
+
if (toggle && !this.element.contains(toggle)) {
|
|
155
|
+
event.preventDefault()
|
|
156
|
+
this.panelTarget.open = !this.panelTarget.open
|
|
157
|
+
if (this.panelTarget.open) this.summary.focus()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Opened by the viewer unless `active: false` (a pop-up, a restore).
|
|
162
|
+
// Returns the window, null when the page already shows the conversation,
|
|
163
|
+
// or false when there is no room for it (see makeRoom).
|
|
164
|
+
openWindow(id, { minimized = false, focus = false, active = true, opener = null } = {}) {
|
|
165
|
+
if (this.renderedOnPage(id)) {
|
|
166
|
+
// Already on the page: go to it rather than open a second copy.
|
|
167
|
+
this.panelTarget.open = false
|
|
168
|
+
document.getElementById(this.domId("composer", id))?.querySelector("textarea")?.focus()
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const open = this.windowFor(id)
|
|
173
|
+
if (open) {
|
|
174
|
+
if (opener) this.openers.set(open, opener)
|
|
175
|
+
return this.show(open, { focus }) ? open : false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!this.makeRoom(null, { force: active })) return false
|
|
179
|
+
|
|
180
|
+
const frame = this.buildWindow(id, { minimized, active, focus })
|
|
181
|
+
if (opener) this.openers.set(frame, opener)
|
|
182
|
+
this.windowsTarget.append(frame)
|
|
183
|
+
return frame
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Every window arrives here, whoever made it: this controller, or the
|
|
187
|
+
// Turbo Stream `chat_button_to …, dock: true` answers with (which renders
|
|
188
|
+
// the same element from chats/dock/_window — keep the two in step). Also
|
|
189
|
+
// called again for each window when a Turbo visit reattaches the dock.
|
|
190
|
+
windowTargetConnected(frame) {
|
|
191
|
+
const id = frame.dataset.conversationId
|
|
192
|
+
if (this.renderedOnPage(id)) return this.dropWindow(frame)
|
|
193
|
+
|
|
194
|
+
frame.dataset.chatsMessager ||= this.messagerValue
|
|
195
|
+
frame.dataset.usedAt ||= String(Date.now())
|
|
196
|
+
this.windowTargets.forEach((other) => {
|
|
197
|
+
if (other !== frame && other.dataset.conversationId === id) this.removeWindow(other)
|
|
198
|
+
})
|
|
199
|
+
// A window the server sent (`dock: true`) and nothing can make room
|
|
200
|
+
// for: the conversation's page instead. A restored one was placed by
|
|
201
|
+
// restore(), which reflows the whole arrangement at once.
|
|
202
|
+
if (frame.hasAttribute("data-restored")) {
|
|
203
|
+
frame.removeAttribute("data-restored")
|
|
204
|
+
} else if (!this.makeRoom(frame, { force: true })) {
|
|
205
|
+
this.dropWindow(frame)
|
|
206
|
+
this.visit(this.threadUrl(id))
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!frame.hasAttribute("data-entered")) {
|
|
211
|
+
frame.setAttribute("data-entered", "")
|
|
212
|
+
this.flash(frame, ENTERING, 260)
|
|
213
|
+
}
|
|
214
|
+
if (frame.hasAttribute("data-focus")) {
|
|
215
|
+
frame.removeAttribute("data-focus")
|
|
216
|
+
this.show(frame, { focus: true })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.syncControls(frame)
|
|
220
|
+
this.markOpenRows()
|
|
221
|
+
this.syncSheet()
|
|
222
|
+
this.save()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A repeated `dock: true` for a conversation already in a window: focus
|
|
226
|
+
// that window. Turbo's append would replace it, reload the thread and
|
|
227
|
+
// drop whatever is typed in it.
|
|
228
|
+
beforeStreamRender(event) {
|
|
229
|
+
const stream = event.target
|
|
230
|
+
if (stream.getAttribute?.("action") !== "append" || stream.getAttribute("target") !== "chats_dock_windows") return
|
|
231
|
+
|
|
232
|
+
const incoming = stream.templateContent?.querySelector("[data-conversation-id]")
|
|
233
|
+
const open = incoming && this.windowFor(incoming.dataset.conversationId)
|
|
234
|
+
if (!open) return
|
|
235
|
+
|
|
236
|
+
event.preventDefault()
|
|
237
|
+
if (!this.show(open, { focus: true })) this.visit(this.threadUrl(open.dataset.conversationId))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Bring a window forward: expanded, engaged, in front on a phone. False
|
|
241
|
+
// when it can't expand (see expand): it stays as it was.
|
|
242
|
+
show(frame, { focus = false } = {}) {
|
|
243
|
+
if (!this.expand(frame)) return false
|
|
244
|
+
|
|
245
|
+
frame.dataset.usedAt = String(Date.now())
|
|
246
|
+
this.activate(frame)
|
|
247
|
+
if (this.compact) {
|
|
248
|
+
this.windowTargets.forEach((other) => other.classList.toggle(FRONT, other === frame))
|
|
249
|
+
this.syncSheet()
|
|
250
|
+
}
|
|
251
|
+
if (focus) this.focusInside(frame)
|
|
252
|
+
return true
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Unfold a window. Expanding takes width, so it makes room exactly as an
|
|
256
|
+
// open does (closing idle windows, folding the others in use) and, when
|
|
257
|
+
// even that can't fit it, doesn't happen: the window stays folded.
|
|
258
|
+
expand(frame) {
|
|
259
|
+
if (!this.minimized(frame)) return true
|
|
260
|
+
if (!this.compact && !this.makeRoom(frame, { force: true, expanding: true })) return false
|
|
261
|
+
|
|
262
|
+
this.setMinimized(frame, false)
|
|
263
|
+
return true
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
buildWindow(id, { minimized, active, focus }) {
|
|
267
|
+
const frame = document.createElement("turbo-frame")
|
|
268
|
+
frame.id = this.domId("window", id)
|
|
269
|
+
frame.className = minimized ? `chats-window ${MINIMIZED}` : "chats-window"
|
|
270
|
+
if (minimized) frame.setAttribute("data-chats-minimized", "")
|
|
271
|
+
frame.setAttribute("target", "_top")
|
|
272
|
+
frame.setAttribute("data-chats--dock-target", "window")
|
|
273
|
+
frame.dataset.conversationId = id
|
|
274
|
+
frame.dataset.chatsMessager = this.messagerValue
|
|
275
|
+
frame.dataset.chatsActive = String(active)
|
|
276
|
+
if (focus) frame.dataset.focus = ""
|
|
277
|
+
frame.append(this.shell("loading", this.rowName(id)))
|
|
278
|
+
frame.src = this.windowUrlValue.replace("__id__", encodeURIComponent(id))
|
|
279
|
+
return frame
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Make room for one more window (or, with +keeping+, for that one), or
|
|
283
|
+
// say there is none. Two limits, both absolute:
|
|
284
|
+
//
|
|
285
|
+
// * the cap — never more than `windows` windows, expanded or folded;
|
|
286
|
+
// * the width — every window's slot (336px expanded or minimized, the
|
|
287
|
+
// 104px folded header of a squeezed one, each with its gap) fits
|
|
288
|
+
// beside the panel.
|
|
289
|
+
//
|
|
290
|
+
// Room is made by closing the least recently used window that isn't IN
|
|
291
|
+
// USE. A window in use — it has the focus, or unsent text — is never
|
|
292
|
+
// closed: a window the viewer is opening (`force`) folds it to its
|
|
293
|
+
// narrow header instead, least recently used first and the focused one
|
|
294
|
+
// last, and a pop-up gives up. When even that isn't enough, there is no
|
|
295
|
+
// room, and the caller opens the conversation's page instead.
|
|
296
|
+
makeRoom(keeping, { force = false, expanding = false } = {}) {
|
|
297
|
+
const others = () => this.windowTargets.filter((frame) => frame !== keeping)
|
|
298
|
+
const incoming = expanding ? this.windowWidth + this.gap : this.slot(keeping)
|
|
299
|
+
|
|
300
|
+
// Could it fit at all — every window not in use closed, and (opening)
|
|
301
|
+
// every one in use folded? If not, change nothing.
|
|
302
|
+
const kept = others().filter((frame) => this.inUse(frame))
|
|
303
|
+
if (kept.length + 1 > this.windowsValue) return false
|
|
304
|
+
if (!this.compact) {
|
|
305
|
+
const least = kept.reduce((total, frame) => total + (force ? this.squeezedWidth + this.gap : this.slot(frame)), 0)
|
|
306
|
+
if (least + incoming > this.room) return false
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
while (others().length + 1 > this.windowsValue) {
|
|
310
|
+
const victim = this.leastRecentlyUsed(keeping)
|
|
311
|
+
if (!victim) return false
|
|
312
|
+
this.removeWindow(victim)
|
|
313
|
+
}
|
|
314
|
+
if (this.compact) return true // one at a time on a phone: only the cap
|
|
315
|
+
|
|
316
|
+
while (!this.fits(others(), incoming)) {
|
|
317
|
+
const victim = this.leastRecentlyUsed(keeping)
|
|
318
|
+
if (victim) {
|
|
319
|
+
this.removeWindow(victim)
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
const fold = force && this.foldable(keeping)
|
|
323
|
+
if (!fold) return false
|
|
324
|
+
this.squeeze(fold)
|
|
325
|
+
}
|
|
326
|
+
return true
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// After the viewport narrowed: back within both limits. First close what
|
|
330
|
+
// isn't in use, then fold what is; and when even a row of folded headers
|
|
331
|
+
// is too wide, close windows in use too, least recently used first and
|
|
332
|
+
// the focused one last — their unsent text is kept (removeWindow), which
|
|
333
|
+
// is what drafts are for. Nothing is announced. Returns whether anything
|
|
334
|
+
// changed.
|
|
335
|
+
reflow() {
|
|
336
|
+
let changed = false
|
|
337
|
+
while (this.windowTargets.length > this.windowsValue || !this.fits(this.windowTargets)) {
|
|
338
|
+
const idle = this.leastRecentlyUsed(null)
|
|
339
|
+
const fold = !idle && this.foldable(null)
|
|
340
|
+
const last = !idle && !fold && this.lastInUse()
|
|
341
|
+
if (fold) {
|
|
342
|
+
this.squeeze(fold)
|
|
343
|
+
} else if (idle || last) {
|
|
344
|
+
this.removeWindow(idle || last)
|
|
345
|
+
} else {
|
|
346
|
+
break
|
|
347
|
+
}
|
|
348
|
+
changed = true
|
|
349
|
+
}
|
|
350
|
+
return changed
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The window in use to give up when nothing else is left: least recently
|
|
354
|
+
// used, the focused one last.
|
|
355
|
+
lastInUse() {
|
|
356
|
+
return this.windowTargets.slice().sort((a, b) => {
|
|
357
|
+
const focus = Number(a.contains(document.activeElement)) - Number(b.contains(document.activeElement))
|
|
358
|
+
return focus || Number(a.dataset.usedAt || 0) - Number(b.dataset.usedAt || 0)
|
|
359
|
+
})[0]
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Folded to make room: minimized, at the narrow header width.
|
|
363
|
+
squeeze(frame) {
|
|
364
|
+
this.setMinimized(frame, true)
|
|
365
|
+
frame.classList.add(SQUEEZED)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The next window in use that can still give width back: not squeezed
|
|
369
|
+
// yet, least recently used first, the one with the focus last.
|
|
370
|
+
foldable(keeping) {
|
|
371
|
+
return this.windowTargets
|
|
372
|
+
.filter((frame) => frame !== keeping && !frame.classList.contains(SQUEEZED))
|
|
373
|
+
.sort((a, b) => {
|
|
374
|
+
const focus = Number(a.contains(document.activeElement)) - Number(b.contains(document.activeElement))
|
|
375
|
+
return focus || Number(a.dataset.usedAt || 0) - Number(b.dataset.usedAt || 0)
|
|
376
|
+
})[0]
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// The width a window takes in the row, its gap included; null is a new
|
|
380
|
+
// (expanded) one.
|
|
381
|
+
slot(frame) {
|
|
382
|
+
const width = frame?.classList.contains(SQUEEZED) ? this.squeezedWidth : this.windowWidth
|
|
383
|
+
return width + this.gap
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
fits(frames, incoming = 0) {
|
|
387
|
+
return frames.reduce((total, frame) => total + this.slot(frame), incoming) <= this.room
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// What the row may take: the viewport, less the right inset, the panel
|
|
391
|
+
// and the 8px left margin the spec keeps (the fit formula's 312px).
|
|
392
|
+
get room() {
|
|
393
|
+
const panel = this.panelTarget.offsetWidth || this.pixels("--chats-dock-panel-width", 288)
|
|
394
|
+
return window.innerWidth - this.pixels("--chats-dock-inset", 16) - panel - this.gap
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
get gap() {
|
|
398
|
+
return this.pixels("--chats-dock-gap", 8)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
get windowWidth() {
|
|
402
|
+
return this.pixels("--chats-dock-window-width", 336)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
get squeezedWidth() {
|
|
406
|
+
return this.pixels("--chats-dock-squeezed-width", 104)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// How many expanded windows fit beside the panel right now
|
|
410
|
+
// (min(windows, floor((vw − 312) / 344))). On a phone they are shown one
|
|
411
|
+
// at a time, so only the cap applies.
|
|
412
|
+
get fit() {
|
|
413
|
+
if (this.compact) return this.windowsValue
|
|
414
|
+
|
|
415
|
+
return Math.max(1, Math.min(this.windowsValue, Math.floor(this.room / (this.windowWidth + this.gap))))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// --- Live ------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
// The stream replaces the signal whenever something changed for this
|
|
421
|
+
// viewer (Chats::Broadcasts.update_dock_of). The server-rendered one
|
|
422
|
+
// carries no data-changed; a broadcast one does, consumed here so the
|
|
423
|
+
// same node reconnecting (a Turbo Drive visit) never acts twice.
|
|
424
|
+
signalTargetConnected(signal) {
|
|
425
|
+
if (!signal.hasAttribute("data-changed")) return
|
|
426
|
+
signal.removeAttribute("data-changed")
|
|
427
|
+
this.refreshList()
|
|
428
|
+
|
|
429
|
+
const id = signal.dataset.conversationId
|
|
430
|
+
if (!id || !signal.hasAttribute("data-notify")) return
|
|
431
|
+
signal.removeAttribute("data-notify")
|
|
432
|
+
if (this.element.hidden) return
|
|
433
|
+
|
|
434
|
+
this.popUp(id, Number(signal.dataset.unreadCount || 0))
|
|
435
|
+
this.announce(id, signal.dataset.senderName)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// A message arrived that the viewer would be notified about (the server
|
|
439
|
+
// has already ruled out their own and muted conversations).
|
|
440
|
+
popUp(id, unread) {
|
|
441
|
+
const open = this.windowFor(id)
|
|
442
|
+
if (open) {
|
|
443
|
+
if (this.minimized(open) || !this.engaged(open)) this.markUnread(open, unread)
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
// A phone shows one window and it fills the screen: a message never
|
|
447
|
+
// takes the screen away from what its owner is doing. The badge says it.
|
|
448
|
+
if (this.compact || this.renderedOnPage(id) || this.recentlyClosed(id)) return
|
|
449
|
+
|
|
450
|
+
const frame = this.openWindow(id, { active: false })
|
|
451
|
+
if (!frame) return
|
|
452
|
+
this.markUnread(frame, unread)
|
|
453
|
+
this.flash(frame, POPPED, 1300)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
markUnread(frame, count) {
|
|
457
|
+
frame.classList.add(UNREAD)
|
|
458
|
+
frame.dataset.unread = String(count || "")
|
|
459
|
+
this.syncControls(frame)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// "New message from …" in the polite live region: at most once per
|
|
463
|
+
// conversation every 30 seconds, never for the window you're typing in.
|
|
464
|
+
announce(id, name) {
|
|
465
|
+
if (!name || !this.hasAnnouncerTarget) return
|
|
466
|
+
if (this.windowFor(id)?.contains(document.activeElement)) return
|
|
467
|
+
|
|
468
|
+
const now = Date.now()
|
|
469
|
+
if (now - (this.announced[id] || 0) < ANNOUNCE_EVERY_MS) return
|
|
470
|
+
this.announced[id] = now
|
|
471
|
+
|
|
472
|
+
this.announcerTarget.textContent = ""
|
|
473
|
+
requestAnimationFrame(() => {
|
|
474
|
+
this.announcerTarget.textContent = this.announcementValue.replace("%{name}", name)
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
catchUp() {
|
|
479
|
+
this.refreshList()
|
|
480
|
+
if (this.badgeUrlValue) renderStreamFrom(this.badgeUrlValue)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
badgeTargetConnected(badge) {
|
|
484
|
+
const count = Number(badge.dataset.count || 0)
|
|
485
|
+
if (this.lastCount !== undefined && count > this.lastCount) this.flash(badge, "chats-badge--bumped", 200)
|
|
486
|
+
this.lastCount = count
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Debounced while open; marked stale while closed (it reloads on open).
|
|
490
|
+
// A list that never loaded will load fresh when it is first shown.
|
|
491
|
+
refreshList() {
|
|
492
|
+
if (!this.hasListTarget || !this.listTarget.hasAttribute("complete")) return
|
|
493
|
+
|
|
494
|
+
if (!this.panelTarget.open) {
|
|
495
|
+
this.listStale = true
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
clearTimeout(this.listTimer)
|
|
499
|
+
this.listTimer = setTimeout(() => this.listTarget.reload(), LIST_DELAY_MS)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
panelToggled() {
|
|
503
|
+
if (this.panelTarget.open && this.listStale) {
|
|
504
|
+
this.listStale = false
|
|
505
|
+
this.listTarget.reload()
|
|
506
|
+
}
|
|
507
|
+
if (!this.panelTarget.open && this.compact) {
|
|
508
|
+
this.windowTargets.forEach((frame) => frame.classList.remove(FRONT))
|
|
509
|
+
}
|
|
510
|
+
this.syncSheet()
|
|
511
|
+
this.save()
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// A send from one of our windows: the draft is gone, the list reorders.
|
|
515
|
+
sent(event) {
|
|
516
|
+
if (!event.detail?.success) return
|
|
517
|
+
|
|
518
|
+
const frame = this.frameOf(event.target)
|
|
519
|
+
if (!frame) return
|
|
520
|
+
delete frame.savedDraft
|
|
521
|
+
this.forgetDraft(frame.dataset.conversationId)
|
|
522
|
+
this.refreshList()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
typed(event) {
|
|
526
|
+
const frame = this.frameOf(event.target)
|
|
527
|
+
if (!frame || !event.target.matches(".chats-composer__input")) return
|
|
528
|
+
|
|
529
|
+
clearTimeout(frame.draftTimer)
|
|
530
|
+
frame.draftTimer = setTimeout(() => this.keepDraft(frame), DRAFT_DELAY_MS)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// --- Engagement --------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
// Focus, a press, a wheel or a touch inside a window: the viewer is with
|
|
536
|
+
// it. It becomes the most recently used, and an expanded one may read.
|
|
537
|
+
engage(event) {
|
|
538
|
+
const frame = this.frameOf(event.target)
|
|
539
|
+
if (!frame) return
|
|
540
|
+
|
|
541
|
+
frame.dataset.usedAt = String(Date.now())
|
|
542
|
+
if (!this.minimized(frame)) this.activate(frame)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
activate(frame) {
|
|
546
|
+
const waiting = !this.engaged(frame) || frame.classList.contains(UNREAD)
|
|
547
|
+
frame.dataset.chatsActive = "true"
|
|
548
|
+
frame.classList.remove(UNREAD)
|
|
549
|
+
delete frame.dataset.unread
|
|
550
|
+
this.syncControls(frame)
|
|
551
|
+
// The thread controller reads what waited for the viewer.
|
|
552
|
+
if (waiting) frame.dispatchEvent(new CustomEvent("chats:activated"))
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
engaged(frame) {
|
|
556
|
+
return frame.dataset.chatsActive === "true"
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// --- Window controls -------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
// The title button and the – button: fold or unfold. Unfolding is the
|
|
562
|
+
// viewer turning to the window, so it counts as engaging with it.
|
|
563
|
+
minimize(event) {
|
|
564
|
+
const frame = this.frameOf(event.target)
|
|
565
|
+
if (!frame || this.compact) return
|
|
566
|
+
|
|
567
|
+
if (this.minimized(frame)) {
|
|
568
|
+
this.show(frame) // no room: it stays folded
|
|
569
|
+
} else {
|
|
570
|
+
this.setMinimized(frame, true)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
close(event) {
|
|
575
|
+
this.closeWindow(this.frameOf(event.target))
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Phones: back to the list the window came from. syncSheet() gives the
|
|
579
|
+
// list back its focusability; the focus goes to its header.
|
|
580
|
+
back(event) {
|
|
581
|
+
const frame = this.frameOf(event.target)
|
|
582
|
+
frame?.classList.remove(FRONT)
|
|
583
|
+
this.panelTarget.open = true
|
|
584
|
+
this.syncSheet()
|
|
585
|
+
this.summary.focus()
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
retry(event) {
|
|
589
|
+
const frame = this.frameOf(event.target)
|
|
590
|
+
if (!frame) return
|
|
591
|
+
|
|
592
|
+
this.keepDraft(frame)
|
|
593
|
+
frame.replaceChildren(this.shell("loading", this.rowName(frame.dataset.conversationId)))
|
|
594
|
+
frame.reload()
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Closed by the viewer: it won't pop open again for five minutes, and the
|
|
598
|
+
// focus goes back where they came from.
|
|
599
|
+
closeWindow(frame) {
|
|
600
|
+
if (!frame) return
|
|
601
|
+
|
|
602
|
+
const hadFocus = frame.contains(document.activeElement)
|
|
603
|
+
const opener = this.openers.get(frame)
|
|
604
|
+
this.closed = { ...this.closed, [frame.dataset.conversationId]: Date.now() }
|
|
605
|
+
this.removeWindow(frame)
|
|
606
|
+
this.save()
|
|
607
|
+
|
|
608
|
+
if (hadFocus) (opener?.isConnected ? opener : this.summary).focus()
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Every way a window goes keeps what was typed in it.
|
|
612
|
+
removeWindow(frame) {
|
|
613
|
+
this.dropWindow(frame)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// The one way a window leaves the dock, whatever the reason (closed,
|
|
617
|
+
// evicted, declined, missing or refused, shown by the page, another
|
|
618
|
+
// viewer's): its draft kept for this viewer — skipped only for a window
|
|
619
|
+
// that names another account, whose words are not this viewer's to keep —
|
|
620
|
+
// and the rows and the phone sheet told — a window gone from in front of the list must give the list
|
|
621
|
+
// back its focusability.
|
|
622
|
+
dropWindow(frame, { keepDraft = true } = {}) {
|
|
623
|
+
if (keepDraft) this.keepDraft(frame)
|
|
624
|
+
frame.remove()
|
|
625
|
+
this.markOpenRows()
|
|
626
|
+
this.syncSheet()
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
setMinimized(frame, minimized) {
|
|
630
|
+
if (this.minimized(frame) === minimized) return
|
|
631
|
+
|
|
632
|
+
frame.classList.toggle(MINIMIZED, minimized)
|
|
633
|
+
if (!minimized) frame.classList.remove(SQUEEZED)
|
|
634
|
+
this.syncControls(frame)
|
|
635
|
+
this.save()
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// What depends on a window's state inside content the server may have
|
|
639
|
+
// just replaced: the disclosure state, the folded body taken out of the
|
|
640
|
+
// tab order, the waiting count.
|
|
641
|
+
syncControls(frame) {
|
|
642
|
+
const minimized = this.minimized(frame)
|
|
643
|
+
// What the thread controller reads to know it mustn't mark anything read.
|
|
644
|
+
frame.toggleAttribute("data-chats-minimized", minimized)
|
|
645
|
+
frame.querySelector(".chats-window__title")?.setAttribute("aria-expanded", String(!minimized))
|
|
646
|
+
frame.querySelectorAll(".chats-thread--window > :not(.chats-window__header), .chats-window__status")
|
|
647
|
+
.forEach((part) => { part.inert = minimized })
|
|
648
|
+
|
|
649
|
+
const badge = frame.querySelector("[data-chats-dock-window-unread]")
|
|
650
|
+
if (badge) {
|
|
651
|
+
const count = Number(frame.dataset.unread || 0)
|
|
652
|
+
badge.hidden = !count
|
|
653
|
+
badge.textContent = count > 99 ? "99+" : String(count || "")
|
|
654
|
+
badge.title = count ? this.unreadLabel(count) : ""
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
frameLoaded(event) {
|
|
659
|
+
const frame = event.target
|
|
660
|
+
if (this.hasListTarget && frame === this.listTarget) return this.markOpenRows()
|
|
661
|
+
if (!this.windowTargets.includes(frame)) return
|
|
662
|
+
|
|
663
|
+
this.syncControls(frame)
|
|
664
|
+
this.restoreDraft(frame) // a composer ejected before 0.5.0 isn't a target: restored here instead
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// A window's composer just appeared: put its saved draft back NOW. Turbo
|
|
668
|
+
// inserts a frame's content, then awaits its render before dispatching
|
|
669
|
+
// turbo:frame-load; in that gap the field is on the page and empty, so a
|
|
670
|
+
// first keystroke could land before the draft and be overwritten by it.
|
|
671
|
+
// Stimulus reports the target as it connects, which closes the gap.
|
|
672
|
+
composerTargetConnected(input) {
|
|
673
|
+
const frame = this.frameOf(input)
|
|
674
|
+
if (frame && this.windowTargets.includes(frame)) this.restoreDraft(frame)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// A window's answer, before Turbo acts on it. A 4xx means the
|
|
678
|
+
// conversation is gone for this viewer (they left, a block hides it):
|
|
679
|
+
// close the window quietly. A 5xx: the failed shell, with Try again. Both
|
|
680
|
+
// must be handled HERE: an error page carrying turbo-visit-control (Rails'
|
|
681
|
+
// own do) would otherwise make Turbo leave the page for it.
|
|
682
|
+
frameResponded(event) {
|
|
683
|
+
const frame = event.target
|
|
684
|
+
const response = event.detail?.fetchResponse
|
|
685
|
+
if (!this.windowTargets.includes(frame) || !response || response.succeeded) return
|
|
686
|
+
|
|
687
|
+
event.preventDefault()
|
|
688
|
+
if (response.clientError) {
|
|
689
|
+
// The old content (and what was typed in it) is still there: kept.
|
|
690
|
+
this.dropWindow(frame)
|
|
691
|
+
this.save()
|
|
692
|
+
} else {
|
|
693
|
+
this.showFailure(frame)
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// The answer had no window in it (a host's page instead of the thread):
|
|
698
|
+
// close it quietly, rather than Turbo printing "Content missing".
|
|
699
|
+
frameMissing(event) {
|
|
700
|
+
if (!this.windowTargets.includes(event.target)) return
|
|
701
|
+
|
|
702
|
+
event.preventDefault()
|
|
703
|
+
this.dropWindow(event.target)
|
|
704
|
+
this.save()
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// No answer at all (offline): say so, with a way to try again.
|
|
708
|
+
frameFailed(event) {
|
|
709
|
+
const frame = event.target
|
|
710
|
+
if (!this.windowTargets.includes(frame)) return
|
|
711
|
+
|
|
712
|
+
this.showFailure(frame)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// The failed shell replaces the window's content, composer included: what
|
|
716
|
+
// was typed in it — maybe within the last 500ms, before the autosave — is
|
|
717
|
+
// saved first, and comes back with the thread on Try again.
|
|
718
|
+
showFailure(frame) {
|
|
719
|
+
this.keepDraft(frame)
|
|
720
|
+
frame.replaceChildren(this.shell("failed", this.rowName(frame.dataset.conversationId)))
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
focusInside(frame) {
|
|
724
|
+
// On a phone the keyboard would cover the history they came to read.
|
|
725
|
+
const selector = this.compact ? ".chats-window__back" : ".chats-composer__input"
|
|
726
|
+
const focus = () => frame.querySelector(selector)?.focus({ preventScroll: true })
|
|
727
|
+
|
|
728
|
+
if (frame.querySelector(".chats-thread--window")) {
|
|
729
|
+
focus()
|
|
730
|
+
} else {
|
|
731
|
+
frame.addEventListener("turbo:frame-load", focus, { once: true })
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// --- Keyboard --------------------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
keydown(event) {
|
|
738
|
+
if (event.key !== "Escape" || event.defaultPrevented) return
|
|
739
|
+
// The thread's long-press popup and photo preview close themselves.
|
|
740
|
+
if (event.target.closest(".chats-popup, .chats-attachment-preview")) return
|
|
741
|
+
if (this.element.querySelector(".chats-popup:not([hidden]), .chats-attachment-preview:not([hidden])")) return
|
|
742
|
+
|
|
743
|
+
const menu = event.target.closest("details.chats-window__menu[open]")
|
|
744
|
+
if (menu) {
|
|
745
|
+
menu.open = false
|
|
746
|
+
menu.querySelector("summary")?.focus()
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const frame = this.frameOf(event.target)
|
|
751
|
+
if (this.compact) {
|
|
752
|
+
if (frame?.classList.contains(FRONT)) return this.back({ target: frame })
|
|
753
|
+
if (this.panelTarget.open) {
|
|
754
|
+
this.panelTarget.open = false
|
|
755
|
+
this.summary.focus()
|
|
756
|
+
}
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (frame && !this.minimized(frame)) {
|
|
761
|
+
this.setMinimized(frame, true)
|
|
762
|
+
frame.querySelector(".chats-window__title")?.focus()
|
|
763
|
+
} else if (!frame && this.panelTarget.open) {
|
|
764
|
+
this.panelTarget.open = false
|
|
765
|
+
this.summary.focus()
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// --- Pages -------------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
// After every visit: hidden on the pages that ask for it, and no window
|
|
772
|
+
// for a conversation the page itself now renders.
|
|
773
|
+
pageChanged() {
|
|
774
|
+
const hide = !this.everywhereValue &&
|
|
775
|
+
Array.from(document.querySelectorAll("[data-chats-dock-hide]")).some((node) => !this.element.contains(node))
|
|
776
|
+
this.element.hidden = hide
|
|
777
|
+
if (hide) this.panelTarget.open = false
|
|
778
|
+
|
|
779
|
+
this.windowTargets.forEach((frame) => {
|
|
780
|
+
if (this.renderedOnPage(frame.dataset.conversationId)) this.removeWindow(frame)
|
|
781
|
+
})
|
|
782
|
+
this.markOpenRows()
|
|
783
|
+
this.syncSheet()
|
|
784
|
+
this.save()
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// A frame of the page itself loaded a conversation (the two-pane messaging
|
|
788
|
+
// page opens one beside its list without a visit): no window keeps a
|
|
789
|
+
// second copy of it.
|
|
790
|
+
pageFrameLoaded(event) {
|
|
791
|
+
if (this.element.contains(event.target)) return
|
|
792
|
+
|
|
793
|
+
this.windowTargets.forEach((frame) => {
|
|
794
|
+
if (this.renderedOnPage(frame.dataset.conversationId)) this.removeWindow(frame)
|
|
795
|
+
})
|
|
796
|
+
this.save()
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// A row whose conversation is open in a window says so.
|
|
800
|
+
markOpenRows() {
|
|
801
|
+
if (!this.hasListTarget) return
|
|
802
|
+
|
|
803
|
+
this.listTarget.querySelectorAll("[data-chats-dock-conversation-id]").forEach((row) => {
|
|
804
|
+
if (this.windowFor(String(row.dataset.chatsDockConversationId))) {
|
|
805
|
+
row.setAttribute("aria-current", "true")
|
|
806
|
+
} else {
|
|
807
|
+
row.removeAttribute("aria-current")
|
|
808
|
+
}
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// --- Memory ------------------------------------------------------------------------
|
|
813
|
+
//
|
|
814
|
+
// localStorage may be missing, full, or refuse access (private modes,
|
|
815
|
+
// blocked site data): the dock then simply forgets. Every access is
|
|
816
|
+
// guarded.
|
|
817
|
+
|
|
818
|
+
get storageKey() {
|
|
819
|
+
return `chats:dock:v1:${this.messagerValue}`
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
save() {
|
|
823
|
+
const saved = this.read(this.storageKey) || {}
|
|
824
|
+
this.write(this.storageKey, {
|
|
825
|
+
// The sheet always starts closed: only the dock's panel is remembered.
|
|
826
|
+
panel: this.compact ? Boolean(saved.panel) : this.panelTarget.open,
|
|
827
|
+
windows: this.windowTargets.map((frame) => ({
|
|
828
|
+
id: frame.dataset.conversationId,
|
|
829
|
+
minimized: this.minimized(frame),
|
|
830
|
+
folded: frame.classList.contains(SQUEEZED),
|
|
831
|
+
usedAt: Number(frame.dataset.usedAt || 0)
|
|
832
|
+
})),
|
|
833
|
+
closed: this.freshClosed()
|
|
834
|
+
})
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Once per page load (a Turbo visit keeps the live element and skips it):
|
|
838
|
+
// the saved arrangement as it was — order, folded or expanded, the
|
|
839
|
+
// narrow headers included, up to the cap — then reflow() brings it within
|
|
840
|
+
// the width the viewport has NOW, through the same rules as a resize
|
|
841
|
+
// (close idle windows, fold the ones in use, …). None of them engaged.
|
|
842
|
+
restore() {
|
|
843
|
+
const saved = this.read(this.storageKey)
|
|
844
|
+
if (!saved || this.windowTargets.length > 0) return
|
|
845
|
+
|
|
846
|
+
const windows = Array.isArray(saved.windows) ? saved.windows.filter((entry) => entry?.id) : []
|
|
847
|
+
windows.slice(-this.windowsValue).forEach((entry) => {
|
|
848
|
+
const id = String(entry.id)
|
|
849
|
+
if (this.renderedOnPage(id) || this.windowFor(id)) return
|
|
850
|
+
|
|
851
|
+
const frame = this.buildWindow(id, { minimized: Boolean(entry.minimized), active: false, focus: false })
|
|
852
|
+
if (entry.folded) frame.classList.add(SQUEEZED)
|
|
853
|
+
if (entry.usedAt) frame.dataset.usedAt = String(entry.usedAt)
|
|
854
|
+
frame.setAttribute("data-restored", "") // placed by this arrangement, not by makeRoom
|
|
855
|
+
this.windowsTarget.append(frame)
|
|
856
|
+
})
|
|
857
|
+
if (!this.compact) this.reflow()
|
|
858
|
+
if (!this.compact && saved.panel) this.panelTarget.open = true
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
recentlyClosed(id) {
|
|
862
|
+
return Date.now() - (this.closed?.[id] || 0) < NO_REPOP_MS
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
freshClosed() {
|
|
866
|
+
const now = Date.now()
|
|
867
|
+
const closed = this.closed && typeof this.closed === "object" ? this.closed : {}
|
|
868
|
+
return Object.fromEntries(Object.entries(closed).filter(([, at]) => now - Number(at) < NO_REPOP_MS))
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
draftKey(id) {
|
|
872
|
+
return `chats:draft:${this.messagerValue}:${id}`
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
keepDraft(frame) {
|
|
876
|
+
const input = frame.querySelector(".chats-composer__input")
|
|
877
|
+
if (!input) return
|
|
878
|
+
|
|
879
|
+
// A saved draft that couldn't be restored (see restoreDraft) is never
|
|
880
|
+
// replaced by something shorter typed over it.
|
|
881
|
+
if (frame.savedDraft !== undefined) {
|
|
882
|
+
if (input.value.trim().length < frame.savedDraft.trim().length) return
|
|
883
|
+
delete frame.savedDraft
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (input.value.trim()) {
|
|
887
|
+
this.write(this.draftKey(frame.dataset.conversationId), { text: input.value, at: Date.now() })
|
|
888
|
+
} else {
|
|
889
|
+
this.forgetDraft(frame.dataset.conversationId)
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// The rule: a saved draft goes back only into an EMPTY composer. If the
|
|
894
|
+
// viewer already typed something (the restore ran late — an ejected
|
|
895
|
+
// composer restores on turbo:frame-load), what they typed stays in the
|
|
896
|
+
// field, and the saved draft stays saved: no autosave replaces it with
|
|
897
|
+
// anything shorter (keepDraft), so neither text is lost.
|
|
898
|
+
restoreDraft(frame) {
|
|
899
|
+
const input = frame.querySelector(".chats-composer__input")
|
|
900
|
+
const draft = this.read(this.draftKey(frame.dataset.conversationId))
|
|
901
|
+
if (!input || !draft) return
|
|
902
|
+
|
|
903
|
+
if (typeof draft.text !== "string" || Date.now() - (draft.at || 0) >= DRAFT_TTL_MS) {
|
|
904
|
+
this.forgetDraft(frame.dataset.conversationId)
|
|
905
|
+
} else if (input.value) {
|
|
906
|
+
if (input.value !== draft.text) frame.savedDraft = draft.text
|
|
907
|
+
} else {
|
|
908
|
+
input.value = draft.text
|
|
909
|
+
this.forgetDraft(frame.dataset.conversationId)
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
forgetDraft(id) {
|
|
914
|
+
try {
|
|
915
|
+
window.localStorage.removeItem(this.draftKey(id))
|
|
916
|
+
} catch {
|
|
917
|
+
// nothing was remembered
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
read(key) {
|
|
922
|
+
try {
|
|
923
|
+
return JSON.parse(window.localStorage.getItem(key) || "null")
|
|
924
|
+
} catch {
|
|
925
|
+
return null
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
write(key, value) {
|
|
930
|
+
try {
|
|
931
|
+
window.localStorage.setItem(key, JSON.stringify(value))
|
|
932
|
+
} catch {
|
|
933
|
+
// nothing to remember with
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// --- Layout ------------------------------------------------------------------------
|
|
938
|
+
|
|
939
|
+
// The phone layout is one media query in chats.css (so it holds before
|
|
940
|
+
// this controller connects, and without JavaScript); the same query
|
|
941
|
+
// decides the behaviour here.
|
|
942
|
+
layout = () => {
|
|
943
|
+
const compact = this.media.matches
|
|
944
|
+
if (this.compact === true && !compact) this.windowTargets.forEach((frame) => frame.classList.remove(FRONT))
|
|
945
|
+
if (this.compact === false && compact) this.panelTarget.open = false
|
|
946
|
+
|
|
947
|
+
this.compact = compact
|
|
948
|
+
this.element.dataset.mode = compact ? "sheet" : "dock"
|
|
949
|
+
this.fitViewport()
|
|
950
|
+
this.resized()
|
|
951
|
+
this.syncSheet()
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// A narrower viewport: back down to what fits (see reflow).
|
|
955
|
+
resized = () => {
|
|
956
|
+
if (this.compact) return
|
|
957
|
+
cancelAnimationFrame(this.resizeFrame)
|
|
958
|
+
this.resizeFrame = requestAnimationFrame(() => {
|
|
959
|
+
if (this.reflow()) this.save()
|
|
960
|
+
})
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Full-screen on a phone means the VISIBLE viewport: with the keyboard up
|
|
964
|
+
// it is shorter than 100dvh, and the composer must sit on the keys.
|
|
965
|
+
fitViewport = () => {
|
|
966
|
+
const viewport = window.visualViewport
|
|
967
|
+
if (!viewport) return
|
|
968
|
+
|
|
969
|
+
this.element.style.setProperty("--chats-vvh", `${viewport.height}px`)
|
|
970
|
+
this.element.style.setProperty("--chats-vv-top", `${viewport.offsetTop}px`)
|
|
971
|
+
this.element.classList.toggle("chats-dock--keyboard", this.compact && window.innerHeight - viewport.height > 150)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// On a phone, an open sheet is modal: the page behind can't scroll or
|
|
975
|
+
// take the focus — and neither can the list behind a window in front of
|
|
976
|
+
// it, until Back.
|
|
977
|
+
syncSheet() {
|
|
978
|
+
const front = Boolean(this.compact) && this.windowTargets.some((frame) => frame.classList.contains(FRONT))
|
|
979
|
+
const open = Boolean(this.compact) && !this.element.hidden && (this.panelTarget.open || front)
|
|
980
|
+
this.panelTarget.inert = open && front
|
|
981
|
+
document.documentElement.classList.toggle("chats-dock-sheet-open", open)
|
|
982
|
+
if (open) {
|
|
983
|
+
if (this.inerted.length) return
|
|
984
|
+
this.inerted = this.pageAround().filter((node) => !node.inert)
|
|
985
|
+
this.inerted.forEach((node) => { node.inert = true })
|
|
986
|
+
} else {
|
|
987
|
+
this.releaseSheet()
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Everything on the page but the dock: the siblings of the dock and of
|
|
992
|
+
// each of its ancestors, up to <body>. A host may render the dock inside
|
|
993
|
+
// a layout wrapper, and inerting the wrapper would inert the sheet too.
|
|
994
|
+
pageAround() {
|
|
995
|
+
const around = []
|
|
996
|
+
for (let node = this.element; node?.parentElement && node !== document.body; node = node.parentElement) {
|
|
997
|
+
around.push(...Array.from(node.parentElement.children).filter((sibling) => sibling !== node))
|
|
998
|
+
}
|
|
999
|
+
return around
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
releaseSheet() {
|
|
1003
|
+
this.inerted?.forEach((node) => { node.inert = false })
|
|
1004
|
+
this.inerted = []
|
|
1005
|
+
if (this.compact !== undefined) document.documentElement.classList.remove("chats-dock-sheet-open")
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// --- Lookups ---------------------------------------------------------------------
|
|
1009
|
+
|
|
1010
|
+
get summary() {
|
|
1011
|
+
return this.panelTarget.querySelector("summary")
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
windowFor(id) {
|
|
1015
|
+
return this.windowTargets.find((frame) => frame.dataset.conversationId === id)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
frameOf(element) {
|
|
1019
|
+
return element?.closest?.(".chats-window")
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
minimized(frame) {
|
|
1023
|
+
return frame.classList.contains(MINIMIZED)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
leastRecentlyUsed(keeping) {
|
|
1027
|
+
return this.windowTargets
|
|
1028
|
+
.filter((frame) => frame !== keeping && !this.inUse(frame))
|
|
1029
|
+
.sort((a, b) => Number(a.dataset.usedAt || 0) - Number(b.dataset.usedAt || 0))[0]
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Being typed in, or holding words not yet sent.
|
|
1033
|
+
// A window whose thread hasn't arrived yet (just restored) holds what its
|
|
1034
|
+
// saved draft holds.
|
|
1035
|
+
inUse(frame) {
|
|
1036
|
+
if (frame.contains(document.activeElement)) return true
|
|
1037
|
+
|
|
1038
|
+
const input = frame.querySelector(".chats-composer__input")
|
|
1039
|
+
if (input) return Boolean(input.value.trim())
|
|
1040
|
+
return Boolean(this.read(this.draftKey(frame.dataset.conversationId))?.text?.trim())
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Whether the page itself (not one of our windows) renders this thread.
|
|
1044
|
+
renderedOnPage(id) {
|
|
1045
|
+
return Array.from(document.querySelectorAll(`[id="${CSS.escape(this.domId("messages", id))}"]`))
|
|
1046
|
+
.some((element) => !this.element.contains(element))
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// The conversation's own page (the window's URL, without `window=1`).
|
|
1050
|
+
threadUrl(id) {
|
|
1051
|
+
const url = new URL(this.windowUrlValue.replace("__id__", encodeURIComponent(id)), window.location.href)
|
|
1052
|
+
url.searchParams.delete("window")
|
|
1053
|
+
return url.toString()
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
visit(url) {
|
|
1057
|
+
if (window.Turbo?.visit) {
|
|
1058
|
+
window.Turbo.visit(url)
|
|
1059
|
+
} else {
|
|
1060
|
+
window.location.href = url
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// dom_id(conversation, prefix), as the server writes it.
|
|
1065
|
+
domId(prefix, id) {
|
|
1066
|
+
return `${prefix}_${this.recordKeyValue}_${id}`
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// The name a row shows for a conversation, for a window's shell header.
|
|
1070
|
+
rowName(id) {
|
|
1071
|
+
const row = this.hasListTarget && this.listTarget.querySelector(`[data-chats-dock-conversation-id="${CSS.escape(id)}"]`)
|
|
1072
|
+
return row?.querySelector(".chats-row__title")?.textContent?.trim() || null
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
shell(state, name) {
|
|
1076
|
+
const template = state === "failed" ? this.failedShellTarget : this.loadingShellTarget
|
|
1077
|
+
const content = template.content.cloneNode(true)
|
|
1078
|
+
if (name) {
|
|
1079
|
+
const label = content.querySelector("[data-chats-dock-name]")
|
|
1080
|
+
if (label) label.textContent = name
|
|
1081
|
+
}
|
|
1082
|
+
return content
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// "3 new messages", in the plural form the page's language uses for 3:
|
|
1086
|
+
// Polish says 2, 3, 4, 22 differently from 5 and 1; Russian says 21 like 1.
|
|
1087
|
+
// The dock renders every form its locale has (chats.dock.window_unread:
|
|
1088
|
+
// zero, one, two, few, many, other); a form the locale lacks reads as
|
|
1089
|
+
// `other`.
|
|
1090
|
+
unreadLabel(count) {
|
|
1091
|
+
let forms
|
|
1092
|
+
try {
|
|
1093
|
+
forms = JSON.parse(this.windowUnreadValue || "{}")
|
|
1094
|
+
} catch {
|
|
1095
|
+
return ""
|
|
1096
|
+
}
|
|
1097
|
+
return (forms[this.pluralCategory(count)] ?? forms.other)?.replace("%{count}", count) || ""
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// CLDR's category for +count+ in the page's language (<html lang>, else
|
|
1101
|
+
// the browser's). Without Intl, or with a language tag it can't read, the
|
|
1102
|
+
// old two-form rule.
|
|
1103
|
+
pluralCategory(count) {
|
|
1104
|
+
try {
|
|
1105
|
+
return new Intl.PluralRules(document.documentElement.lang || navigator.language || undefined).select(count)
|
|
1106
|
+
} catch {
|
|
1107
|
+
return count === 1 ? "one" : "other"
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
pixels(property, fallback) {
|
|
1112
|
+
const value = getComputedStyle(this.element).getPropertyValue(property).trim()
|
|
1113
|
+
return value.endsWith("px") ? parseFloat(value) : fallback
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
flash(element, className, duration) {
|
|
1117
|
+
element.classList.add(className)
|
|
1118
|
+
setTimeout(() => element.classList.remove(className), duration)
|
|
1119
|
+
}
|
|
1120
|
+
}
|