chats 0.4.1 → 0.5.0

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