chats 0.1.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1037 -0
  3. data/README.md +621 -40
  4. data/app/assets/stylesheets/chats.css +906 -2
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +396 -86
  7. data/app/controllers/chats/messages_controller.rb +35 -13
  8. data/app/controllers/chats/reactions_controller.rb +14 -0
  9. data/app/helpers/chats/engine_helper.rb +230 -7
  10. data/app/javascript/chats/composer_controller.js +14 -1
  11. data/app/javascript/chats/dock_controller.js +1120 -0
  12. data/app/javascript/chats/recovery.js +539 -0
  13. data/app/javascript/chats/refresh_inbox_controller.js +76 -0
  14. data/app/javascript/chats/split_controller.js +131 -0
  15. data/app/javascript/chats/thread_controller.js +322 -60
  16. data/app/javascript/chats/unread_badge_controller.js +24 -0
  17. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  18. data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
  19. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  20. data/app/views/chats/conversations/_group.html.erb +61 -0
  21. data/app/views/chats/conversations/_history.html.erb +54 -0
  22. data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
  23. data/app/views/chats/conversations/_thread.html.erb +147 -0
  24. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  25. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  26. data/app/views/chats/conversations/dock.html.erb +40 -0
  27. data/app/views/chats/conversations/index.html.erb +142 -15
  28. data/app/views/chats/conversations/pane.html.erb +14 -0
  29. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  30. data/app/views/chats/conversations/show.html.erb +18 -134
  31. data/app/views/chats/conversations/window.html.erb +26 -0
  32. data/app/views/chats/dock/_badge.html.erb +12 -0
  33. data/app/views/chats/dock/_dock.html.erb +139 -0
  34. data/app/views/chats/dock/_signal.html.erb +26 -0
  35. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  36. data/app/views/chats/dock/_window.html.erb +23 -0
  37. data/app/views/chats/dock/_window_header.html.erb +75 -0
  38. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  39. data/app/views/chats/messages/_action.html.erb +15 -0
  40. data/app/views/chats/messages/_composer.html.erb +84 -56
  41. data/app/views/chats/messages/_message.html.erb +43 -15
  42. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  43. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  44. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  45. data/config/importmap.rb +10 -1
  46. data/config/locales/en.yml +40 -0
  47. data/config/locales/es.yml +40 -0
  48. data/config/routes.rb +8 -0
  49. data/context7.json +4 -0
  50. data/docs/PRD.md +1 -1
  51. data/docs/campfire_review.md +1 -1
  52. data/docs/screenshots/composer-actions.png +0 -0
  53. data/docs/screenshots/dock-bar.png +0 -0
  54. data/docs/screenshots/dock-desktop.png +0 -0
  55. data/docs/screenshots/dock-phone-button.png +0 -0
  56. data/docs/screenshots/dock-phone-panel.png +0 -0
  57. data/docs/screenshots/dock-phone-window.png +0 -0
  58. data/docs/screenshots/inbox.png +0 -0
  59. data/docs/screenshots/split-inbox-empty.png +0 -0
  60. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  61. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  62. data/docs/screenshots/split-inbox.png +0 -0
  63. data/docs/screenshots/system-note-action.png +0 -0
  64. data/docs/screenshots/thread-actions-phone.png +0 -0
  65. data/gemfiles/rails_7.1.gemfile +3 -0
  66. data/gemfiles/rails_7.2.gemfile +3 -0
  67. data/gemfiles/rails_8.1.gemfile +3 -0
  68. data/lib/chats/action.rb +85 -0
  69. data/lib/chats/broadcasts.rb +178 -17
  70. data/lib/chats/configuration.rb +115 -1
  71. data/lib/chats/engine.rb +29 -7
  72. data/lib/chats/errors.rb +16 -0
  73. data/lib/chats/inbox.rb +363 -0
  74. data/lib/chats/inbox_group.rb +75 -0
  75. data/lib/chats/inbox_refresh_job.rb +14 -0
  76. data/lib/chats/macros.rb +33 -1
  77. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  78. data/lib/chats/models/concerns/messager.rb +122 -6
  79. data/lib/chats/models/conversation.rb +153 -19
  80. data/lib/chats/models/message.rb +292 -21
  81. data/lib/chats/models/participant.rb +67 -0
  82. data/lib/chats/models/reaction.rb +18 -0
  83. data/lib/chats/send_rate_limited.rb +30 -0
  84. data/lib/chats/subscribers.rb +156 -0
  85. data/lib/chats/version.rb +1 -1
  86. data/lib/chats.rb +123 -15
  87. data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
  88. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  89. data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
  90. data/lib/generators/chats/templates/initializer.rb +125 -14
  91. data/lib/generators/chats/upgrade_generator.rb +79 -0
  92. data/lib/generators/chats/views_generator.rb +3 -2
  93. metadata +51 -2
@@ -0,0 +1,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
+ }