chats 0.6.2 → 0.7.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -0
  3. data/README.md +90 -28
  4. data/app/assets/stylesheets/chats.css +225 -15
  5. data/app/helpers/chats/engine_helper.rb +101 -7
  6. data/app/javascript/chats/dock_controller.js +54 -33
  7. data/app/javascript/chats/sheet.js +69 -0
  8. data/app/javascript/chats/thread_controller.js +199 -5
  9. data/app/javascript/chats/tray_controller.js +506 -0
  10. data/app/views/chats/conversations/_thread.html.erb +4 -0
  11. data/app/views/chats/dock/_dock.html.erb +9 -1
  12. data/app/views/chats/messages/_message.html.erb +30 -3
  13. data/app/views/chats/tray/_badge.html.erb +12 -0
  14. data/app/views/chats/tray/_corner.html.erb +16 -0
  15. data/app/views/chats/tray/_item.html.erb +44 -0
  16. data/app/views/chats/tray/_tray.html.erb +27 -0
  17. data/config/importmap.rb +4 -0
  18. data/config/locales/en.yml +1 -0
  19. data/config/locales/es.yml +1 -0
  20. data/lib/chats/after_commit.rb +87 -0
  21. data/lib/chats/broadcasts.rb +8 -0
  22. data/lib/chats/models/participant.rb +14 -1
  23. data/lib/chats/subscribers.rb +14 -1
  24. data/lib/chats/version.rb +1 -1
  25. data/lib/generators/chats/templates/initializer.rb +1 -0
  26. metadata +8 -16
  27. data/docs/PRD.md +0 -254
  28. data/docs/campfire_review.md +0 -46
  29. data/docs/screenshots/composer-actions.png +0 -0
  30. data/docs/screenshots/dock-bar.png +0 -0
  31. data/docs/screenshots/dock-desktop.png +0 -0
  32. data/docs/screenshots/dock-phone-button.png +0 -0
  33. data/docs/screenshots/dock-phone-panel.png +0 -0
  34. data/docs/screenshots/dock-phone-window.png +0 -0
  35. data/docs/screenshots/inbox.png +0 -0
  36. data/docs/screenshots/split-inbox-empty.png +0 -0
  37. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  38. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  39. data/docs/screenshots/split-inbox.png +0 -0
  40. data/docs/screenshots/system-note-action.png +0 -0
  41. data/docs/screenshots/thread-actions-phone.png +0 -0
@@ -0,0 +1,506 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
3
+ import { around, fitViewport, holdInert, lockScroll, releaseInert } from "chats/sheet"
4
+
5
+ // chats--tray: the items a host puts in the corner (`chats_dock { … }`, or
6
+ // `chats_tray { … }` on its own), each a <details> rendered by
7
+ // `chats_tray_item`: closed, a tab on the bottom edge (a round button on a
8
+ // phone); open, a panel with the dock's skin (a full-screen sheet on a
9
+ // phone). The server renders the items and their content; this controller
10
+ // runs them:
11
+ //
12
+ // * one open opening an item closes the others, and (`chats--tray:open`,
13
+ // handled by chats--dock synchronously) the dock's panel;
14
+ // the dock's panel opening closes the open item
15
+ // * the read gate every item carries data-chats-active and, while closed,
16
+ // data-chats-minimized: the attributes a dock window has,
17
+ // which chats' thread controller obeys, so a thread inside
18
+ // a closed item never reads. Opening it dispatches
19
+ // `chats:activated`, and a read that waited happens then.
20
+ // * the sheet on a phone an open item is modal: everything around it
21
+ // inert and the page still (chats/sheet, shared with the
22
+ // dock's own sheet, so one closing never frees the other's)
23
+ // * keyboard Escape closes the open item, focus back on its tab (the
24
+ // thread's own popups close themselves first)
25
+ // * memory which item was open, on a desk only (a sheet always
26
+ // starts closed), per owner in localStorage
27
+ // (`chats:tray:v1:<owner>`). Every access is guarded.
28
+ // * badges an item's badge (`chats_tray_badge`) arrives on its
29
+ // owner's stream; what the stream missed (chats/recovery)
30
+ // is asked again from the item's data-chats-tray-badge-url.
31
+ // When a badge's count moves, the frames of the item's
32
+ // content marked [data-chats-tray-refresh] reload: now if
33
+ // the item is open, when it opens if it is closed.
34
+ // * never twice a thread the page shows (or a dock window) is never
35
+ // also shown in an item: the item's
36
+ // turbo-frame[data-chats-tray-home] goes back home. The
37
+ // live streams address a thread's ids, which must exist once.
38
+ // * room an open item is as wide as the corner leaves it: the
39
+ // screen less the insets, the dock's panel and every other
40
+ // item (`--chats-tray-room`, measured); when that is less
41
+ // than `--chats-tray-min-width`, it opens as the sheet, as
42
+ // on a phone (`.chats-tray__item--sheet`)
43
+ // * visits the tray is carried from page to page (permanent), and
44
+ // reconciled with the items the new page rendered: their
45
+ // words, badges, badge URLs, streams and panels are the new
46
+ // page's; only an item's open state, and a frame open in it
47
+ // that the new page renders too (what is being read), stay
48
+ // * on its own (`standalone`: a corner of its own, no dock around it)
49
+ // it also hides on any page carrying [data-chats-dock-hide]
50
+ // and fits the visual viewport, which the dock otherwise does
51
+ //
52
+ // Registered automatically as "chats--tray" through the engine's importmap
53
+ // pin (see config/importmap.rb and Chats::Engine).
54
+
55
+ const SHEET = "(max-width: 699.98px), (max-height: 499.98px)" // keep in step with chats.css
56
+ const AS_SHEET = "chats-tray__item--sheet"
57
+ const REFRESH_DELAY_MS = 300
58
+ // What this controller manages on an item, which a visit never takes from
59
+ // the new page (refreshItem): its attributes, its classes, its properties.
60
+ const MANAGED_ATTRIBUTES = new Set(["open", "data-chats-minimized", "data-chats-active"])
61
+ const MANAGED_CLASSES = [AS_SHEET]
62
+ const MANAGED_PROPERTIES = ["--chats-tray-room"]
63
+
64
+ export default class extends Controller {
65
+ static targets = ["item", "badge"]
66
+ static values = { owner: String, standalone: Boolean }
67
+
68
+ // Stimulus runs the target callbacks BEFORE connect(): set up here.
69
+ initialize() {
70
+ this.counts = new WeakMap()
71
+ this.stale = new WeakSet()
72
+ }
73
+
74
+ connect() {
75
+ this.corner = this.standaloneValue ? this.element.closest(".chats-dock") || this.element : null
76
+ this.media = window.matchMedia(SHEET)
77
+ this.media.addEventListener?.("change", this.layout)
78
+ window.addEventListener("resize", this.resized)
79
+ if (this.corner) {
80
+ window.visualViewport?.addEventListener("resize", this.fitViewport)
81
+ window.visualViewport?.addEventListener("scroll", this.fitViewport)
82
+ }
83
+
84
+ this.layout()
85
+ this.restore()
86
+ this.pageChanged()
87
+
88
+ this.stopRecovery = recoverMissedBroadcasts(this.element, () => this.catchUp(),
89
+ { sources: ".chats-tray__launcher turbo-cable-stream-source" })
90
+ }
91
+
92
+ disconnect() {
93
+ this.media?.removeEventListener?.("change", this.layout)
94
+ window.removeEventListener("resize", this.resized)
95
+ window.visualViewport?.removeEventListener("resize", this.fitViewport)
96
+ window.visualViewport?.removeEventListener("scroll", this.fitViewport)
97
+ clearTimeout(this.refreshTimer)
98
+ cancelAnimationFrame(this.resizeFrame)
99
+ this.releaseSheet()
100
+ this.stopRecovery?.()
101
+ }
102
+
103
+ // --- Items ---------------------------------------------------------------
104
+
105
+ itemTargetConnected(item) {
106
+ item.dataset.chatsActive = "true"
107
+ item.toggleAttribute("data-chats-minimized", !item.open)
108
+ if (this.compact) item.classList.add(AS_SHEET)
109
+ rendered(item) // its frames, as the page rendered them (refreshItem)
110
+ }
111
+
112
+ // Every item's `toggle` (it doesn't bubble: each item names this action).
113
+ toggled(event) {
114
+ const item = event.target
115
+ if (!this.itemTargets.includes(item)) return
116
+
117
+ item.toggleAttribute("data-chats-minimized", !item.open)
118
+ if (item.open) {
119
+ this.itemTargets.forEach((other) => { if (other !== item) other.open = false })
120
+ this.fit(item)
121
+ this.dispatch("open", { target: item, detail: { key: item.dataset.chatsTrayKey } })
122
+ item.dispatchEvent(new CustomEvent("chats:activated"))
123
+ if (this.stale.has(item)) this.refresh(item)
124
+ } else {
125
+ this.dispatch("close", { target: item, detail: { key: item.dataset.chatsTrayKey } })
126
+ }
127
+ this.syncSheet()
128
+ this.save()
129
+ }
130
+
131
+ get openItem() {
132
+ return this.itemTargets.find((item) => item.open)
133
+ }
134
+
135
+ close(item) {
136
+ if (!item?.open) return
137
+
138
+ item.open = false
139
+ item.querySelector("summary")?.focus()
140
+ }
141
+
142
+ // --- Keyboard ------------------------------------------------------------
143
+
144
+ keydown(event) {
145
+ if (event.key !== "Escape" || event.defaultPrevented) return
146
+ // The thread's long-press popup and photo preview close themselves.
147
+ if (event.target.closest?.(".chats-popup, .chats-attachment-preview")) return
148
+ if (this.element.querySelector(".chats-popup:not([hidden]), .chats-attachment-preview:not([hidden])")) return
149
+
150
+ const item = this.openItem
151
+ if (!item) return
152
+
153
+ event.preventDefault()
154
+ this.close(item)
155
+ }
156
+
157
+ // --- Badges and their content ---------------------------------------------
158
+
159
+ // A badge arrived (the server's, or one its owner's stream replaced): when
160
+ // the count moved, what the item shows may be stale.
161
+ badgeTargetConnected(badge) {
162
+ const item = badge.closest(".chats-tray__item")
163
+ if (!item) return
164
+
165
+ const count = Number(badge.dataset.count || 0)
166
+ const before = this.counts.get(item)
167
+ this.counts.set(item, count)
168
+ if (before === undefined || before === count) return
169
+
170
+ if (count > before) this.flash(badge, "chats-badge--bumped", 200)
171
+ this.refresh(item)
172
+ }
173
+
174
+ // Reload the frames that asked to follow the badge: debounced while open,
175
+ // marked stale while closed (they reload on open).
176
+ refresh(item) {
177
+ if (!item.open) {
178
+ this.stale.add(item)
179
+ return
180
+ }
181
+ this.stale.delete(item)
182
+ clearTimeout(this.refreshTimer)
183
+ this.refreshTimer = setTimeout(() => {
184
+ this.refreshableFrames(item).forEach((frame) => frame.reload?.())
185
+ }, REFRESH_DELAY_MS)
186
+ }
187
+
188
+ refreshableFrames(item) {
189
+ const frames = new Set()
190
+ item.querySelectorAll("[data-chats-tray-refresh]").forEach((marker) => {
191
+ const frame = marker.closest("turbo-frame")
192
+ if (frame && item.contains(frame)) frames.add(frame)
193
+ })
194
+ return Array.from(frames)
195
+ }
196
+
197
+ // Each item's badge asked again. Only from this site: what comes back is a
198
+ // Turbo Stream applied to the page, so a badge URL on another origin (one
199
+ // that answers CORS) is refused, never fetched.
200
+ catchUp() {
201
+ this.itemTargets.forEach((item) => {
202
+ const url = sameOrigin(item.dataset.chatsTrayBadgeUrl)
203
+ if (url) renderStreamFrom(url)
204
+ })
205
+ }
206
+
207
+ // --- Pages ---------------------------------------------------------------
208
+
209
+ // A visit is about to render, and Turbo will keep this (permanent) tray
210
+ // rather than the one the new page rendered: make its items the new
211
+ // page's, by key. One the page no longer renders goes; a new one comes in;
212
+ // one still there takes everything the new page says about it (see
213
+ // refreshItem), in the new page's order.
214
+ reconcile(event) {
215
+ const find = (id) => event.detail?.newBody?.querySelector?.(`[id="${cssEscape(id)}"]`)
216
+ // The permanent element the tray rides in (the dock, or its own corner):
217
+ // only when the new page renders it too does Turbo carry this tray there.
218
+ const holder = this.element.closest("[data-turbo-permanent]")
219
+ const newHolder = holder ? find(holder.id) : find(this.element.id)
220
+ if (!newHolder) return
221
+
222
+ const incoming = holder ? newHolder.querySelector(`[id="${cssEscape(this.element.id)}"]`) : newHolder
223
+ const current = new Map(this.itemTargets.map((item) => [item.dataset.chatsTrayKey, item]))
224
+ const wanted = incoming ? Array.from(incoming.querySelectorAll(":scope > .chats-tray__item")) : []
225
+ const keys = new Set(wanted.map((item) => item.dataset.chatsTrayKey))
226
+ const sameOrder = wanted.map((item) => item.dataset.chatsTrayKey).join("\n") === Array.from(current.keys()).join("\n")
227
+
228
+ current.forEach((item, key) => { if (!keys.has(key)) item.remove() })
229
+ wanted.forEach((fresh) => {
230
+ const kept = current.get(fresh.dataset.chatsTrayKey)
231
+ if (kept) this.refreshItem(kept, fresh)
232
+ // The new page's order. When it is the same, nothing moves: a move
233
+ // reconnects what is in the item (its streams, an open thread).
234
+ if (!kept || !sameOrder) this.element.append(kept || document.importNode(fresh, true))
235
+ })
236
+ }
237
+
238
+ // A retained item takes the new page's version of itself: its attributes
239
+ // (class, style and the badge URL among them), its tab (words, icon,
240
+ // badge, the badge's stream) and its panel. It keeps only what this
241
+ // controller manages (MANAGED_*: open or not, the read gate, the sheet and
242
+ // the room it measured) and, while it is open, a frame of its panel that
243
+ // the new page renders as the previous page did, live and in place: what
244
+ // is being read or written there.
245
+ refreshItem(kept, fresh) {
246
+ adopt(kept, fresh, MANAGED_ATTRIBUTES)
247
+
248
+ const summary = kept.querySelector(":scope > summary")
249
+ const freshSummary = fresh.querySelector(":scope > summary")
250
+ if (freshSummary && summary) {
251
+ summary.replaceWith(document.importNode(freshSummary, true))
252
+ } else if (freshSummary) {
253
+ kept.prepend(document.importNode(freshSummary, true))
254
+ }
255
+
256
+ const body = kept.querySelector(":scope > .chats-tray__body")
257
+ const freshBody = fresh.querySelector(":scope > .chats-tray__body")
258
+ if (!body || !freshBody) return
259
+ adopt(body, freshBody)
260
+
261
+ const staying = new Set()
262
+ const keep = new Map()
263
+ if (kept.open) {
264
+ const live = new Map(Array.from(body.querySelectorAll(":scope > turbo-frame[id]"), (frame) => [frame.id, frame]))
265
+ Array.from(freshBody.children).forEach((node) => {
266
+ const frame = live.get(node.id)
267
+ if (frame && sameFrame(frame, node)) {
268
+ staying.add(frame)
269
+ keep.set(node, frame)
270
+ }
271
+ })
272
+ }
273
+ Array.from(body.childNodes).forEach((node) => { if (!staying.has(node)) node.remove() })
274
+ let anchor = body.firstChild
275
+ Array.from(freshBody.childNodes).forEach((node) => {
276
+ const frame = keep.get(node)
277
+ if (frame) {
278
+ anchor = frame.nextSibling // it stays where it is
279
+ } else {
280
+ body.insertBefore(rendered(document.importNode(node, true)), anchor)
281
+ }
282
+ })
283
+ }
284
+
285
+ // After every visit: on its own, hidden on the pages that hide the dock;
286
+ // anywhere, no item keeps a thread the page now shows.
287
+ pageChanged() {
288
+ if (this.corner) {
289
+ const hide = Array.from(document.querySelectorAll("[data-chats-dock-hide]"))
290
+ .some((node) => !this.corner.contains(node))
291
+ this.corner.hidden = hide
292
+ if (hide) this.itemTargets.forEach((item) => { item.open = false })
293
+ }
294
+ this.yieldDuplicates()
295
+ this.syncSheet()
296
+ }
297
+
298
+ // A frame loaded somewhere (a dock window, the page's pane, an item's own):
299
+ // the same rule.
300
+ frameLoaded() {
301
+ this.yieldDuplicates()
302
+ }
303
+
304
+ // A thread is identified by its history's id (dom_id(conversation,
305
+ // :history)): one inside an item that also exists outside it sends the
306
+ // item's home frames back home.
307
+ yieldDuplicates() {
308
+ this.itemTargets.forEach((item) => {
309
+ const twice = Array.from(item.querySelectorAll(".chats-thread__scroll[id]")).some((history) =>
310
+ Array.from(document.querySelectorAll(`[id="${cssEscape(history.id)}"]`)).some((other) => !item.contains(other)))
311
+ if (!twice) return
312
+
313
+ item.querySelectorAll("turbo-frame[data-chats-tray-home]").forEach((frame) => {
314
+ frame.replaceChildren()
315
+ frame.src = frame.dataset.chatsTrayHome
316
+ })
317
+ })
318
+ }
319
+
320
+ // --- The sheet -------------------------------------------------------------
321
+
322
+ layout = () => {
323
+ const compact = this.media.matches
324
+ if (this.compact === false && compact) this.itemTargets.forEach((item) => { item.open = false })
325
+
326
+ this.compact = compact
327
+ if (this.corner) this.corner.dataset.mode = compact ? "sheet" : "dock"
328
+ this.itemTargets.forEach((item) => (item.open ? this.fit(item) : item.classList.toggle(AS_SHEET, compact)))
329
+ this.fitViewport()
330
+ this.syncSheet()
331
+ }
332
+
333
+ // The viewport changed size: the open item fits again (or becomes the sheet).
334
+ // At most once a frame, as the dock reflows (a resize fires many times).
335
+ resized = () => {
336
+ cancelAnimationFrame(this.resizeFrame)
337
+ this.resizeFrame = requestAnimationFrame(() => {
338
+ const item = this.openItem
339
+ if (!item) return
340
+
341
+ this.fit(item)
342
+ this.syncSheet()
343
+ })
344
+ }
345
+
346
+ // How wide +item+ may open: the screen less the insets, the dock's panel
347
+ // and every other item with its gap. Too little (under
348
+ // --chats-tray-min-width), or a phone: the sheet.
349
+ fit(item) {
350
+ const room = this.roomFor(item)
351
+ item.style.setProperty("--chats-tray-room", `${Math.max(0, Math.floor(room))}px`)
352
+ item.classList.toggle(AS_SHEET, Boolean(this.compact) || room < this.pixels("--chats-tray-min-width", 280))
353
+ }
354
+
355
+ roomFor(item) {
356
+ const gap = this.pixels("--chats-dock-gap", 8)
357
+ const panel = this.element.closest(".chats-dock")?.querySelector(":scope > .chats-dock__panel")
358
+ let taken = 2 * this.pixels("--chats-dock-inset", 16) + (panel ? panel.offsetWidth + gap : 0)
359
+ this.itemTargets.forEach((other) => { if (other !== item) taken += other.offsetWidth + gap })
360
+ return window.innerWidth - taken
361
+ }
362
+
363
+ pixels(property, fallback) {
364
+ const value = getComputedStyle(this.element).getPropertyValue(property).trim()
365
+ return value.endsWith("px") ? parseFloat(value) : fallback
366
+ }
367
+
368
+ fitViewport = () => {
369
+ if (this.corner) fitViewport(this.corner, this.compact)
370
+ }
371
+
372
+ // Open as the sheet (a phone, or a corner too narrow for it), an item is
373
+ // modal: everything around it is held inert, and the page doesn't scroll,
374
+ // until it closes.
375
+ syncSheet() {
376
+ const item = this.openItem
377
+ const hidden = this.corner?.hidden || this.element.closest(".chats-dock")?.hidden
378
+ if (item?.classList.contains(AS_SHEET) && !hidden) {
379
+ holdInert(this, around(item))
380
+ lockScroll(this, true)
381
+ } else {
382
+ this.releaseSheet()
383
+ }
384
+ }
385
+
386
+ releaseSheet() {
387
+ releaseInert(this)
388
+ lockScroll(this, false)
389
+ }
390
+
391
+ // --- Memory ----------------------------------------------------------------
392
+
393
+ get storageKey() {
394
+ return `chats:tray:v1:${this.ownerValue}`
395
+ }
396
+
397
+ save() {
398
+ const saved = this.read() || {}
399
+ const open = this.openItem
400
+ // A sheet always starts closed: on a phone, only a desk's choice is kept,
401
+ // and an item that opened as the sheet on a narrow desk isn't remembered.
402
+ if (this.compact) return this.write({ open: saved.open || null })
403
+ this.write({ open: open && !open.classList.contains(AS_SHEET) ? open.dataset.chatsTrayKey : null })
404
+ }
405
+
406
+ // The item that was open, on a desk. A Turbo visit carries the live
407
+ // element along, so this only ever reopens what is already open.
408
+ restore() {
409
+ if (this.compact || this.openItem) return
410
+
411
+ const key = this.read()?.open
412
+ if (typeof key !== "string") return
413
+ const item = this.itemTargets.find((candidate) => candidate.dataset.chatsTrayKey === key)
414
+ if (item) item.open = true
415
+ }
416
+
417
+ read() {
418
+ try {
419
+ const saved = JSON.parse(window.localStorage.getItem(this.storageKey) || "null")
420
+ return saved && typeof saved === "object" ? saved : null
421
+ } catch {
422
+ return null
423
+ }
424
+ }
425
+
426
+ write(value) {
427
+ try {
428
+ window.localStorage.setItem(this.storageKey, JSON.stringify(value))
429
+ } catch {
430
+ // nothing to remember with
431
+ }
432
+ }
433
+
434
+ flash(element, className, duration) {
435
+ element.classList.add(className)
436
+ setTimeout(() => element.classList.remove(className), duration)
437
+ }
438
+ }
439
+
440
+ // +target+ takes +source+'s attributes: every one it carries, and none it
441
+ // doesn't, except the +managed+ ones (kept as they are); its class and style
442
+ // are the source's too, plus the classes and properties this controller
443
+ // manages.
444
+ function adopt(target, source, managed = new Set()) {
445
+ const classes = MANAGED_CLASSES.filter((name) => target.classList.contains(name))
446
+ const properties = MANAGED_PROPERTIES.map((name) => [name, target.style.getPropertyValue(name)]).filter(([, v]) => v)
447
+ Array.from(target.attributes).forEach(({ name }) => {
448
+ if (!managed.has(name) && !source.hasAttribute(name)) target.removeAttribute(name)
449
+ })
450
+ Array.from(source.attributes).forEach(({ name, value }) => {
451
+ if (!managed.has(name)) target.setAttribute(name, value)
452
+ })
453
+ classes.forEach((name) => target.classList.add(name))
454
+ properties.forEach(([name, value]) => target.style.setProperty(name, value))
455
+ }
456
+
457
+ // Whether the new page's +node+ is the frame +frame+ already is: the same id,
458
+ // rendered with the same destination (the src resolved, query included) as
459
+ // the page that rendered +frame+. Where the frame went since (a link followed
460
+ // inside it) is the viewer's, and stays; another destination is another
461
+ // frame (another thread), and replaces it.
462
+ //
463
+ // Each src is resolved against the page that rendered it, at the moment it
464
+ // renders: +frame+'s when it connected (rendered), +node+'s now, on
465
+ // turbo:before-render, when Turbo has already moved the address to the new
466
+ // page. So `src="threads"` on /a/ and on /b/ are two destinations.
467
+ function sameFrame(frame, node) {
468
+ return node.matches?.("turbo-frame") && node.id === frame.id &&
469
+ frame.renderedSrc === resolved(node.getAttribute("src"))
470
+ }
471
+
472
+ // The absolute destination the page rendered a frame with, remembered on it
473
+ // (and on the frames inside it) before Turbo navigates it.
474
+ function rendered(node) {
475
+ const frames = node.matches?.("turbo-frame") ? [node] : []
476
+ frames.push(...(node.querySelectorAll?.("turbo-frame") || []))
477
+ frames.forEach((frame) => { frame.renderedSrc ??= resolved(frame.getAttribute("src")) })
478
+ return node
479
+ }
480
+
481
+ function resolved(src) {
482
+ if (!src) return ""
483
+ try {
484
+ return new URL(src, window.location.href).toString()
485
+ } catch {
486
+ return src
487
+ }
488
+ }
489
+
490
+ // +url+ resolved against the page, when it is on the page's own origin; null
491
+ // (and said, in the console) when it is not.
492
+ function sameOrigin(url) {
493
+ if (!url) return null
494
+ try {
495
+ const resolved = new URL(url, window.location.href)
496
+ if (resolved.origin === window.location.origin) return resolved.toString()
497
+ } catch {
498
+ // not a URL at all
499
+ }
500
+ console.error(`chats--tray: refusing a badge URL on another origin: ${url}`)
501
+ return null
502
+ }
503
+
504
+ function cssEscape(value) {
505
+ return window.CSS?.escape ? window.CSS.escape(value) : String(value).replace(/["\\]/g, "\\$&")
506
+ }
@@ -68,6 +68,7 @@
68
68
  "chats--thread-refresh-url-value": refresh_conversation_path(conversation),
69
69
  "chats--thread-thread-url-value": conversation_path(conversation),
70
70
  "chats--thread-copied-label-value": t("chats.message.copied"),
71
+ "chats--thread-menu-label-value": t("chats.message.menu"),
71
72
  # A plain data attribute (not a Stimulus value): host CSS/JS keys its
72
73
  # own block/report affordances off it. False for a messager declared
73
74
  # `acts_as_messager blockable: false`.
@@ -94,9 +95,12 @@
94
95
  pressed bubble's clone morphs to the center, the reactions pill sits
95
96
  above it and the contextual menu below. Slots are filled by cloning
96
97
  the bubble's <template data-chats-message-menu>. %>
98
+ <%# Modal on every screen (the backdrop covers the thread; a tap or a
99
+ click on it closes): chats--thread keeps the focus inside while open. %>
97
100
  <div class="chats-popup"
98
101
  role="dialog"
99
102
  aria-modal="true"
103
+ aria-label="<%= t("chats.message.menu") %>"
100
104
  data-chats--thread-target="popup"
101
105
  hidden>
102
106
  <div class="chats-popup__backdrop" data-action="click->chats--thread#closePopup"></div>
@@ -13,6 +13,10 @@
13
13
  account's page (sign out and in, an account switcher) never matches and
14
14
  never carries one person's windows into another's page.
15
15
 
16
+ Between the windows and the panel, the TRAY (chats/tray/_tray): the
17
+ items the host's block gave `chats_dock { … }`, e.g. support_desk's
18
+ "Need help?". Nothing at all without a block.
19
+
16
20
  The panel is a <details>: it opens and closes with no JavaScript at all,
17
21
  and its rows, search and links are real links and forms. The chats--dock
18
22
  controller (dock_controller.js) does the rest. Its live signal is the
@@ -50,7 +54,9 @@
50
54
  "click@document->chats--dock#delegate " \
51
55
  "turbo:before-stream-render@document->chats--dock#beforeStreamRender " \
52
56
  "turbo:load@document->chats--dock#pageChanged turbo:render@document->chats--dock#pageChanged " \
53
- "turbo:frame-load@document->chats--dock#pageFrameLoaded"
57
+ "turbo:frame-load@document->chats--dock#pageFrameLoaded " \
58
+ "chats--tray:open->chats--dock#trayOpened " \
59
+ "turbo:before-cache@document->chats--dock#releaseSheet"
54
60
  } do %>
55
61
  <%= turbo_stream_from viewer, :chats_dock %>
56
62
  <%= render "chats/dock/signal" %>
@@ -61,6 +67,8 @@
61
67
  <template data-chats--dock-target="loadingShell"><%= render "chats/dock/window_shell", state: :loading %></template>
62
68
  <template data-chats--dock-target="failedShell"><%= render "chats/dock/window_shell", state: :failed %></template>
63
69
 
70
+ <% if (tray = local_assigns[:tray]) %><%= render "chats/tray/tray", tray: tray, owner: key %><% end %>
71
+
64
72
  <details class="chats-dock__panel"
65
73
  data-chats--dock-target="panel"
66
74
  data-action="toggle->chats--dock#panelToggled">
@@ -108,6 +108,25 @@
108
108
  <% end %>
109
109
  </span>
110
110
  </div>
111
+
112
+ <%# The menu's desktop door: "⋯" on the bubble's inner edge (the row
113
+ reverses for own bubbles), the popup right-click opens. chats.css
114
+ shows it to a fine, hovering pointer only; touch long-presses.
115
+ tabindex="-1": a thread is ONE tab stop, which chats--thread
116
+ keeps on one of these (arrows move it). The label is this
117
+ render's language (a live bubble's is the sender's), so
118
+ chats--thread stamps the reader's. %>
119
+ <% unless message.deleted? %>
120
+ <button type="button"
121
+ class="chats-message__menu-trigger"
122
+ tabindex="-1"
123
+ aria-label="<%= t("chats.message.menu") %>"
124
+ aria-haspopup="dialog"
125
+ data-chats-message-menu-trigger
126
+ data-action="chats--thread#openMenu keydown->chats--thread#moveMenuFocus">
127
+ <span aria-hidden="true">⋯</span>
128
+ </button>
129
+ <% end %>
111
130
  </div>
112
131
 
113
132
  <% unless message.deleted? %>
@@ -146,9 +165,12 @@
146
165
  long-press/right-click: the reactions pill lands above the
147
166
  lifted bubble, this contextual menu below it. Items marked
148
167
  data-chats-own-only are stripped from the clone for foreign
149
- messages (cosmetic — the server still authorizes for real).
150
- button_to forms keep working when cloned: the CSRF token is
151
- baked in at render time. %>
168
+ messages, and items marked data-chats-other-only from the clone
169
+ for the viewer's own (cosmetic — the server still authorizes for
170
+ real). button_to forms keep working when cloned: a broadcast
171
+ render's token is nobody's, so chats--thread puts the page's own
172
+ (the csrf-token meta) in each cloned form, and a native submit
173
+ (Turbo off around the thread) passes too. %>
152
174
  <template data-chats-message-menu>
153
175
  <% if Chats.config.reactions && !locked %>
154
176
  <div class="chats-popup__reactions" role="group" aria-label="<%= t("chats.message.react") %>">
@@ -178,6 +200,11 @@
178
200
  role: "menuitem",
179
201
  form: { data: { turbo_confirm: t("chats.message.delete_confirm"), chats_own_only: true } } %>
180
202
  <% end %>
203
+ <%# The host's items (Report, Pin, Reply in private…), after
204
+ ours, in the same menu. Rendered here, so as viewer-agnostic
205
+ as the rest of the bubble: see the message_menu slot in the
206
+ README for what an item may be. %>
207
+ <%= chats_slot :message_menu, message: message %>
181
208
  </div>
182
209
  </template>
183
210
  <% end %>
@@ -0,0 +1,12 @@
1
+ <%# An item's badge (`chats_tray_badge`): hidden at zero, number kept. Its
2
+ owner's stream replaces it by +id+ (and the item's badge_url answers the
3
+ same replace when that stream missed one); chats--tray notices the count
4
+ move and reloads the item's [data-chats-tray-refresh] content. Not a
5
+ live region: it would chatter on every read. %>
6
+ <%= tag.span id: id,
7
+ class: "chats-badge chats-tray__badge",
8
+ hidden: !count.positive?,
9
+ data: { "chats--tray-target": "badge", count: count } do %>
10
+ <span aria-hidden="true"><%= count > 99 ? "99+" : count %></span>
11
+ <span class="chats-visually-hidden"><%= count %> <%= t("chats.dock.unread_suffix") %></span>
12
+ <% end %>
@@ -0,0 +1,16 @@
1
+ <%# The tray on its own (`chats_tray`, or `chats_dock` with a block and no
2
+ viewer): the dock's corner without messaging — same element class, so
3
+ the same `--chats-dock-*` variables, position, z-index and phone layout
4
+ — holding only the tray.
5
+
6
+ data-turbo-permanent, as the dock is: an open item, and whatever its
7
+ frame shows, ride along from page to page. The id names the owner, so a
8
+ visit that lands on another account's page (or a sign in) starts fresh.
9
+
10
+ Locals: tray, owner, launcher (false: no round buttons on a phone). %>
11
+ <%= chats_styles %>
12
+ <%= tag.div id: "chats_tray_#{owner}",
13
+ class: ["chats-dock", "chats-dock--tray-only", ("chats-dock--no-launcher" unless launcher)],
14
+ data: { turbo_permanent: true } do %>
15
+ <%= render "chats/tray/tray", tray: tray, owner: owner, standalone: true %>
16
+ <% end %>