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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +147 -0
- data/README.md +90 -28
- data/app/assets/stylesheets/chats.css +225 -15
- data/app/helpers/chats/engine_helper.rb +101 -7
- data/app/javascript/chats/dock_controller.js +54 -33
- data/app/javascript/chats/sheet.js +69 -0
- data/app/javascript/chats/thread_controller.js +199 -5
- data/app/javascript/chats/tray_controller.js +506 -0
- data/app/views/chats/conversations/_thread.html.erb +4 -0
- data/app/views/chats/dock/_dock.html.erb +9 -1
- data/app/views/chats/messages/_message.html.erb +30 -3
- data/app/views/chats/tray/_badge.html.erb +12 -0
- data/app/views/chats/tray/_corner.html.erb +16 -0
- data/app/views/chats/tray/_item.html.erb +44 -0
- data/app/views/chats/tray/_tray.html.erb +27 -0
- data/config/importmap.rb +4 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/lib/chats/after_commit.rb +87 -0
- data/lib/chats/broadcasts.rb +8 -0
- data/lib/chats/models/participant.rb +14 -1
- data/lib/chats/subscribers.rb +14 -1
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- metadata +8 -16
- data/docs/PRD.md +0 -254
- data/docs/campfire_review.md +0 -46
- data/docs/screenshots/composer-actions.png +0 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/docs/screenshots/inbox.png +0 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/docs/screenshots/system-note-action.png +0 -0
- data/docs/screenshots/thread-actions-phone.png +0 -0
|
@@ -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
|
|
150
|
-
|
|
151
|
-
|
|
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 %>
|