chats 0.4.2 → 0.5.1

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