chats 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +43 -0
  3. data/.simplecov +52 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +17 -0
  6. data/CHANGELOG.md +74 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +384 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/chats.css +818 -0
  12. data/app/controllers/chats/application_controller.rb +65 -0
  13. data/app/controllers/chats/conversations_controller.rb +198 -0
  14. data/app/controllers/chats/messages_controller.rb +118 -0
  15. data/app/controllers/chats/reactions_controller.rb +33 -0
  16. data/app/helpers/chats/engine_helper.rb +212 -0
  17. data/app/javascript/chats/composer_controller.js +258 -0
  18. data/app/javascript/chats/debounced_submit_controller.js +40 -0
  19. data/app/javascript/chats/thread_controller.js +855 -0
  20. data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
  21. data/app/views/chats/conversations/_messages_page.html.erb +16 -0
  22. data/app/views/chats/conversations/_read_state.html.erb +11 -0
  23. data/app/views/chats/conversations/index.html.erb +54 -0
  24. data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
  25. data/app/views/chats/conversations/show.html.erb +137 -0
  26. data/app/views/chats/messages/_composer.html.erb +67 -0
  27. data/app/views/chats/messages/_message.html.erb +158 -0
  28. data/app/views/chats/messages/create.turbo_stream.erb +6 -0
  29. data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
  30. data/app/views/chats/shared/_unread_badge.html.erb +6 -0
  31. data/config/importmap.rb +16 -0
  32. data/config/locales/en.yml +87 -0
  33. data/config/locales/es.yml +87 -0
  34. data/config/routes.rb +24 -0
  35. data/docs/PRD.md +254 -0
  36. data/docs/campfire_review.md +46 -0
  37. data/gemfiles/rails_7.1.gemfile +36 -0
  38. data/gemfiles/rails_7.2.gemfile +36 -0
  39. data/gemfiles/rails_8.1.gemfile +36 -0
  40. data/lib/chats/broadcasts.rb +147 -0
  41. data/lib/chats/configuration.rb +286 -0
  42. data/lib/chats/engine.rb +146 -0
  43. data/lib/chats/errors.rb +20 -0
  44. data/lib/chats/macros.rb +28 -0
  45. data/lib/chats/models/application_record.rb +11 -0
  46. data/lib/chats/models/concerns/chat_subject.rb +35 -0
  47. data/lib/chats/models/concerns/messager.rb +102 -0
  48. data/lib/chats/models/conversation.rb +347 -0
  49. data/lib/chats/models/message.rb +323 -0
  50. data/lib/chats/models/participant.rb +151 -0
  51. data/lib/chats/models/reaction.rb +70 -0
  52. data/lib/chats/version.rb +5 -0
  53. data/lib/chats.rb +188 -0
  54. data/lib/generators/chats/install_generator.rb +62 -0
  55. data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
  56. data/lib/generators/chats/templates/initializer.rb +138 -0
  57. data/lib/generators/chats/views_generator.rb +49 -0
  58. metadata +204 -0
@@ -0,0 +1,855 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Stale-thread refresh + DOM budget (both patterns from Basecamp's
4
+ // Campfire, https://github.com/basecamp/once-campfire — its
5
+ // refresh_room_controller and message_paginator):
6
+ // * refresh when the tab was hidden long enough that the WebSocket was
7
+ // probably reaped (mobile WebViews suspend sockets aggressively), and
8
+ // whenever the Turbo Stream subscription reconnects after a drop;
9
+ // * cap rendered bubbles so day-long sessions in a busy group don't
10
+ // grow the DOM unboundedly (trimmed history stays reachable — the
11
+ // pagination anchor is rebuilt to re-fetch it on scroll-up).
12
+ const REFRESH_AFTER_HIDDEN_MS = 60_000
13
+ // Long-press tuning: Telegram-ish. The move tolerance keeps a scroll
14
+ // gesture from ever reading as a press.
15
+ const LONG_PRESS_MS = 450
16
+ const PRESS_MOVE_TOLERANCE_PX = 12
17
+ // Teardown must outlive the close morph (260ms in chats.css and in hosts'
18
+ // ejected styles) or the clone is yanked mid-flight and the bubble snaps the
19
+ // last few pixels home. Keep this a hair ABOVE the CSS transform duration.
20
+ const POPUP_TRANSITION_MS = 280
21
+ const MAX_RENDERED_MESSAGES = 300
22
+ const TRIM_LEEWAY = 20
23
+
24
+ // chats--thread: everything live about an open conversation.
25
+ //
26
+ // Bubbles arrive VIEWER-AGNOSTIC (one broadcast render is shared by every
27
+ // subscriber — see Chats::Broadcasts), so this controller is what makes the
28
+ // thread personal:
29
+ //
30
+ // * own-vs-other alignment compare each bubble's data-sender-key to our
31
+ // me-value; tag own bubbles with
32
+ // .chats-message--own (CSS does the rest,
33
+ // including revealing the edit/delete actions —
34
+ // cosmetic only; the server authorizes for real)
35
+ // * stick-to-bottom autoscroll on new messages when the viewer is
36
+ // already near the bottom (never yank them up
37
+ // mid-history-read)
38
+ // * mark-as-read debounce a POST to #read when foreign messages
39
+ // arrive while the tab is visible — that POST
40
+ // advances our read horizon and broadcasts fresh
41
+ // read-state to everyone
42
+ // * sent / seen receipts derive per-message ticks client-side from the
43
+ // broadcast read-state payload
44
+ // * day separators recalculate local-calendar markers after the
45
+ // initial render, lazy pagination, and appends
46
+ // * typing indicator a Turbo Stream CUSTOM ACTION (no Action Cable
47
+ // channel of its own) — see registration below
48
+ //
49
+ // Registered automatically by stimulus-rails' eagerLoadControllersFrom
50
+ // because the engine pins this file under controllers/chats/ (identifier:
51
+ // "chats--thread"). See Chats::Engine's importmap initializer.
52
+ export default class extends Controller {
53
+ static targets = [
54
+ "scroller",
55
+ "message",
56
+ "typing",
57
+ "readState",
58
+ "popup",
59
+ "popupReactions",
60
+ "popupBubble",
61
+ "popupMenu",
62
+ "attachmentDialog",
63
+ "attachmentImage"
64
+ ]
65
+ static values = {
66
+ me: String,
67
+ readUrl: String,
68
+ refreshUrl: String,
69
+ threadUrl: String,
70
+ sentLabel: String,
71
+ seenLabel: String,
72
+ todayLabel: String,
73
+ yesterdayLabel: String,
74
+ daySeparatorClass: { type: String, default: "chats-day-separator" },
75
+ typingSuffix: String,
76
+ copiedLabel: String,
77
+ group: Boolean
78
+ }
79
+
80
+ connect() {
81
+ this.registerTypingStreamAction()
82
+
83
+ // targetConnected fires for every server-rendered bubble right after
84
+ // connect; this flag keeps the initial flood from triggering N scrolls
85
+ // and N read POSTs.
86
+ this.booting = true
87
+ requestAnimationFrame(() => {
88
+ this.booting = false
89
+ this.scrollToBottom()
90
+ this.renderReceipts()
91
+ this.renderDaySeparators()
92
+ this.renderMessageGroups()
93
+ })
94
+
95
+ if (this.hasTypingTarget) {
96
+ this.typingTarget.addEventListener("chats:typing", this.showTyping)
97
+ }
98
+ document.addEventListener("visibilitychange", this.visibilityChanged)
99
+ // Capture phase: the synthetic click that follows a long-press release
100
+ // must never reach the pressed bubble's links/forms once the popup is
101
+ // up — one-shot, armed by openPopup().
102
+ this.suppressClickCapture = (event) => {
103
+ if (!this.suppressNextClick) return
104
+ this.suppressNextClick = false
105
+ // Only the release-click on the PRESSED region is synthetic — taps
106
+ // inside the popup (a menu item can be the very next click after a
107
+ // right-click open) are real and must pass.
108
+ if (this.hasPopupTarget && this.popupTarget.contains(event.target)) return
109
+ event.preventDefault()
110
+ event.stopPropagation()
111
+ }
112
+ this.element.addEventListener("click", this.suppressClickCapture, true)
113
+ this.watchStreamSource()
114
+ }
115
+
116
+ disconnect() {
117
+ if (this.hasTypingTarget) {
118
+ this.typingTarget.removeEventListener("chats:typing", this.showTyping)
119
+ }
120
+ document.removeEventListener("visibilitychange", this.visibilityChanged)
121
+ clearTimeout(this.readTimer)
122
+ this.sourceObserver?.disconnect()
123
+ this.element.removeEventListener("click", this.suppressClickCapture, true)
124
+ clearTimeout(this.pressTimer)
125
+ this.teardownPopup()
126
+ clearTimeout(this.typingTimer)
127
+ cancelAnimationFrame(this.daySeparatorFrame)
128
+ cancelAnimationFrame(this.messageGroupFrame)
129
+ document.documentElement.classList.remove("chats-attachment-preview-open")
130
+ }
131
+
132
+ // --- Bubbles ---------------------------------------------------------------
133
+
134
+ messageTargetConnected(element) {
135
+ this.trackLoadCursor(element)
136
+ this.classify(element)
137
+ if (this.booting) return
138
+ // Live appends can land out of order (multi-worker hosts broadcast from
139
+ // concurrent jobs). Re-slot the bubble if its predecessor is newer; the
140
+ // move re-fires this callback, which then proceeds in order.
141
+ if (this.ensureChronological(element)) return
142
+
143
+ // Bubbles (re)connect for three reasons: a new message arriving at the
144
+ // tail, an existing bubble REPLACED in place (edit and tombstone
145
+ // broadcasts re-render the same dom_id), and older history paginating
146
+ // in above. Only a TAIL arrival may move the viewport — autoscrolling
147
+ // on a replace yanked editors to the bottom as if they'd sent a new
148
+ // message, away from the bubble they just edited mid-history.
149
+ const latest = this.messageTargets[this.messageTargets.length - 1] === element
150
+ const own = element.dataset.senderKey === this.meValue
151
+ if (latest && (own || this.nearBottom())) this.scrollToBottom()
152
+ if (!own) {
153
+ // Typing pings are ephemeral and intentionally not coupled to message
154
+ // persistence. Once a real message from that sender arrives, the old
155
+ // "Alice is typing…" signal is stale and should disappear immediately
156
+ // instead of waiting for the timeout. Turbo custom actions are the
157
+ // transport here: https://turbo.hotwired.dev/reference/streams#custom-actions
158
+ this.hideTypingFor(element.dataset.senderKey)
159
+ this.queueRead()
160
+ }
161
+ this.renderReceipts()
162
+ this.scheduleDaySeparators()
163
+ this.scheduleMessageGroups()
164
+ this.trimExcessMessages()
165
+ }
166
+
167
+ classify(element) {
168
+ const own = element.dataset.senderKey && element.dataset.senderKey === this.meValue
169
+ element.classList.toggle("chats-message--own", own)
170
+
171
+ const receipt = element.querySelector("[data-chats-message-receipt]")
172
+ const label = element.querySelector("[data-chats-message-receipt-label]")
173
+ if (receipt && !own) {
174
+ receipt.hidden = true
175
+ receipt.textContent = ""
176
+ delete receipt.dataset.state
177
+ }
178
+ if (label && !own) {
179
+ label.hidden = true
180
+ label.textContent = ""
181
+ }
182
+ }
183
+
184
+ // --- Read state ("Seen") -----------------------------------------------------
185
+
186
+ readStateTargetConnected() {
187
+ if (!this.booting) this.renderReceipts()
188
+ }
189
+
190
+ renderReceipts() {
191
+ if (!this.sentLabelValue) return
192
+
193
+ const ownMessages = this.messageTargets.filter(
194
+ (element) => element.dataset.senderKey === this.meValue
195
+ )
196
+ ownMessages.forEach((message) => this.setReceipt(message, false))
197
+ if (!this.seenLabelValue || !this.hasReadStateTarget) return
198
+
199
+ let horizons
200
+ try {
201
+ horizons = JSON.parse(this.readStateTarget.dataset.horizons || "{}")
202
+ } catch {
203
+ return
204
+ }
205
+
206
+ // Other participants' read horizons (ISO8601 strings — lexicographic
207
+ // comparison is chronological for a fixed format). "Seen" means seen by
208
+ // EVERYONE else, so the effective horizon is the minimum.
209
+ const others = Object.entries(horizons)
210
+ .filter(([key]) => key !== this.meValue)
211
+ .map(([, readAt]) => readAt)
212
+ if (others.length === 0 || others.some((readAt) => !readAt)) return
213
+ const horizon = others.reduce((min, readAt) => (readAt < min ? readAt : min))
214
+
215
+ ownMessages.forEach((message) => {
216
+ this.setReceipt(message, message.dataset.timestamp <= horizon)
217
+ })
218
+ }
219
+
220
+ setReceipt(message, seen) {
221
+ const receipt = message.querySelector("[data-chats-message-receipt]")
222
+ if (!receipt) return
223
+
224
+ receipt.textContent = seen ? "✓✓" : "✓"
225
+ receipt.dataset.state = seen ? "seen" : "sent"
226
+ receipt.hidden = false
227
+
228
+ const label = message.querySelector("[data-chats-message-receipt-label]")
229
+ if (label) {
230
+ label.textContent = seen ? this.seenLabelValue : this.sentLabelValue
231
+ label.hidden = false
232
+ }
233
+ }
234
+
235
+ // --- Day separators ---------------------------------------------------------
236
+
237
+ scheduleDaySeparators() {
238
+ cancelAnimationFrame(this.daySeparatorFrame)
239
+ this.daySeparatorFrame = requestAnimationFrame(() => this.renderDaySeparators())
240
+ }
241
+
242
+ renderDaySeparators() {
243
+ this.element.querySelectorAll("[data-chats-day-separator]").forEach((separator) => separator.remove())
244
+
245
+ let previousDay
246
+ this.messageTargets.forEach((message) => {
247
+ const date = new Date(message.dataset.timestamp)
248
+ if (Number.isNaN(date.getTime())) return
249
+
250
+ const day = this.dayKey(date)
251
+ if (day !== previousDay) message.before(this.daySeparator(date))
252
+ previousDay = day
253
+ })
254
+ }
255
+
256
+ daySeparator(date) {
257
+ const separator = document.createElement("div")
258
+ separator.className = this.daySeparatorClassValue
259
+ separator.dataset.chatsDaySeparator = ""
260
+ separator.textContent = this.dayLabel(date)
261
+ return separator
262
+ }
263
+
264
+ dayLabel(date) {
265
+ const today = new Date()
266
+ const yesterday = new Date(today)
267
+ yesterday.setDate(today.getDate() - 1)
268
+
269
+ if (this.dayKey(date) === this.dayKey(today) && this.todayLabelValue) return this.todayLabelValue
270
+ if (this.dayKey(date) === this.dayKey(yesterday) && this.yesterdayLabelValue) return this.yesterdayLabelValue
271
+
272
+ const options = { day: "numeric", month: "long" }
273
+ if (date.getFullYear() !== today.getFullYear()) options.year = "numeric"
274
+ return new Intl.DateTimeFormat(document.documentElement.lang || undefined, options).format(date)
275
+ }
276
+
277
+ dayKey(date) {
278
+ return [date.getFullYear(), date.getMonth(), date.getDate()].join("-")
279
+ }
280
+
281
+ // --- Consecutive-message grouping ---------------------------------------------
282
+
283
+ scheduleMessageGroups() {
284
+ cancelAnimationFrame(this.messageGroupFrame)
285
+ this.messageGroupFrame = requestAnimationFrame(() => this.renderMessageGroups())
286
+ }
287
+
288
+ renderMessageGroups() {
289
+ const messages = this.messageTargets
290
+
291
+ messages.forEach((message, index) => {
292
+ const previous = messages[index - 1]
293
+ const following = messages[index + 1]
294
+
295
+ message.classList.toggle("chats-message--continuation", this.sameMessageGroup(previous, message))
296
+ message.classList.toggle("chats-message--followed", this.sameMessageGroup(message, following))
297
+ })
298
+ }
299
+
300
+ sameMessageGroup(first, second) {
301
+ if (!first || !second || !first.dataset.senderKey || !second.dataset.senderKey) return false
302
+ if (first.dataset.senderKey !== second.dataset.senderKey) return false
303
+
304
+ const firstDate = new Date(first.dataset.timestamp)
305
+ const secondDate = new Date(second.dataset.timestamp)
306
+ if (Number.isNaN(firstDate.getTime()) || Number.isNaN(secondDate.getTime())) return false
307
+
308
+ return this.dayKey(firstDate) === this.dayKey(secondDate)
309
+ }
310
+
311
+ // --- Attachment preview --------------------------------------------------------
312
+
313
+ openAttachment(event) {
314
+ event.preventDefault()
315
+ if (!this.hasAttachmentDialogTarget || !this.hasAttachmentImageTarget) return
316
+
317
+ const link = event.currentTarget
318
+
319
+ // Media takes the whole screen: drop composer focus first so the
320
+ // on-screen keyboard slides away instead of overlapping the preview
321
+ // (matches every messaging app's behavior).
322
+ document.activeElement?.blur?.()
323
+
324
+ this.attachmentImageTarget.src = link.href
325
+ this.attachmentImageTarget.alt = link.querySelector("img")?.alt || ""
326
+ this.attachmentDialogTarget.hidden = false
327
+ document.documentElement.classList.add("chats-attachment-preview-open")
328
+ this.attachmentDialogTarget.focus({ preventScroll: true })
329
+ }
330
+
331
+ closeAttachment() {
332
+ if (!this.hasAttachmentDialogTarget) return
333
+
334
+ this.attachmentDialogTarget.hidden = true
335
+ document.documentElement.classList.remove("chats-attachment-preview-open")
336
+ this.resetAttachment()
337
+ }
338
+
339
+ closeAttachmentFromBackdrop(event) {
340
+ if (event.target === event.currentTarget) this.closeAttachment()
341
+ }
342
+
343
+ resetAttachment() {
344
+ if (this.hasAttachmentImageTarget) {
345
+ this.attachmentImageTarget.removeAttribute("src")
346
+ this.attachmentImageTarget.alt = ""
347
+ }
348
+ }
349
+
350
+ // --- Mark-as-read ------------------------------------------------------------
351
+
352
+ queueRead() {
353
+ if (document.visibilityState !== "visible") {
354
+ this.pendingRead = true
355
+ return
356
+ }
357
+ clearTimeout(this.readTimer)
358
+ this.readTimer = setTimeout(() => this.postRead(), 700)
359
+ }
360
+
361
+ visibilityChanged = () => {
362
+ if (document.visibilityState === "visible") {
363
+ // Asleep long enough for the socket to have been reaped? Catch up on
364
+ // anything the missed broadcasts carried.
365
+ if (this.hiddenAt && Date.now() - this.hiddenAt > REFRESH_AFTER_HIDDEN_MS) {
366
+ this.refreshThread()
367
+ }
368
+ this.hiddenAt = null
369
+ if (this.pendingRead) {
370
+ this.pendingRead = false
371
+ this.queueRead()
372
+ }
373
+ } else {
374
+ this.hiddenAt = Date.now()
375
+ }
376
+ }
377
+
378
+ postRead() {
379
+ if (!this.readUrlValue) return
380
+ fetch(this.readUrlValue, {
381
+ method: "POST",
382
+ headers: { "X-CSRF-Token": csrfToken(), Accept: "application/json" }
383
+ }).catch(() => {})
384
+ }
385
+
386
+ // --- Typing indicator ---------------------------------------------------------
387
+
388
+ // The broadcast arrives as a <turbo-stream action="chats_typing"> targeting
389
+ // our typing element; the registered action re-dispatches it as a DOM
390
+ // event so the controller (not a global) owns presentation + timeout.
391
+ registerTypingStreamAction() {
392
+ const Turbo = window.Turbo
393
+ if (!Turbo || Turbo.StreamActions.chats_typing) return
394
+
395
+ Turbo.StreamActions.chats_typing = function () {
396
+ this.targetElements.forEach((target) => {
397
+ target.dispatchEvent(
398
+ new CustomEvent("chats:typing", {
399
+ detail: {
400
+ name: this.getAttribute("data-name"),
401
+ key: this.getAttribute("data-key")
402
+ }
403
+ })
404
+ )
405
+ })
406
+ }
407
+ }
408
+
409
+ showTyping = (event) => {
410
+ const { name, key } = event.detail
411
+ if (key === this.meValue) return // our own echo
412
+
413
+ const wasHidden = this.typingTarget.hidden
414
+ this.typingKey = key
415
+ this.typingTarget.textContent = `${name} ${this.typingSuffixValue}`
416
+ this.typingTarget.hidden = false
417
+ if (wasHidden && this.nearBottom()) this.scrollToBottom()
418
+
419
+ clearTimeout(this.typingTimer)
420
+ this.typingTimer = setTimeout(() => {
421
+ this.typingTarget.hidden = true
422
+ this.typingKey = null
423
+ }, 4000)
424
+ }
425
+
426
+ hideTypingFor(key) {
427
+ if (!this.hasTypingTarget) return
428
+ if (key && this.typingKey && this.typingKey !== key) return
429
+
430
+ clearTimeout(this.typingTimer)
431
+ this.typingTarget.hidden = true
432
+ this.typingTarget.textContent = ""
433
+ this.typingKey = null
434
+ }
435
+
436
+ // --- Scrolling ----------------------------------------------------------------
437
+
438
+ nearBottom() {
439
+ if (!this.hasScrollerTarget) return true
440
+ const el = this.scrollerTarget
441
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 120
442
+ }
443
+
444
+ scrollToBottom() {
445
+ if (!this.hasScrollerTarget) return
446
+ this.scrollerTarget.scrollTop = this.scrollerTarget.scrollHeight
447
+ }
448
+
449
+ // --- Stale-thread refresh (catch up after sleep/disconnect) --------------------
450
+
451
+ // The `?since=` cursor: the newest data-updated-at-ms across rendered
452
+ // bubbles. updated_at (not created_at) so edits/tombstones made while
453
+ // asleep are caught by the refresh too.
454
+ trackLoadCursor(element) {
455
+ const ms = Number(element.dataset.updatedAtMs || 0)
456
+ if (ms > (this.lastLoadedAt || 0)) this.lastLoadedAt = ms
457
+ }
458
+
459
+ // Reconnect detection without a dedicated cable channel: turbo-rails
460
+ // toggles a `connected` attribute on <turbo-cable-stream-source> as the
461
+ // Action Cable subscription confirms/drops, so observing that attribute
462
+ // IS the heartbeat. (Campfire ran a HeartbeatChannel for this; the
463
+ // attribute observer gets the same signal for free.)
464
+ watchStreamSource() {
465
+ const source = this.element.querySelector("turbo-cable-stream-source")
466
+ if (!source || typeof MutationObserver === "undefined") return
467
+
468
+ this.sourceObserver = new MutationObserver(() => {
469
+ const connected = source.hasAttribute("connected")
470
+ if (connected && this.streamDropped) {
471
+ this.streamDropped = false
472
+ this.refreshThread()
473
+ } else if (!connected) {
474
+ this.streamDropped = true
475
+ }
476
+ })
477
+ this.sourceObserver.observe(source, { attributes: true, attributeFilter: ["connected"] })
478
+ }
479
+
480
+ refreshThread() {
481
+ if (!this.refreshUrlValue || !this.lastLoadedAt) return
482
+
483
+ const url = `${this.refreshUrlValue}?since=${this.lastLoadedAt}`
484
+ fetch(url, { headers: { Accept: "text/vnd.turbo-stream.html" } })
485
+ .then((response) => (response.ok ? response.text() : ""))
486
+ .then((html) => {
487
+ if (html) window.Turbo?.renderStreamMessage(html)
488
+ })
489
+ .catch(() => {})
490
+ }
491
+
492
+ // --- DOM budget -----------------------------------------------------------------
493
+
494
+ // Day-long sessions in a busy group append without bound; past
495
+ // MAX_RENDERED_MESSAGES (+ leeway, so we trim in batches instead of per
496
+ // message) drop the oldest bubbles. Only when the viewer is parked at the
497
+ // bottom — never yank history out from under someone reading it.
498
+ trimExcessMessages() {
499
+ const all = this.messageTargets
500
+ if (all.length <= MAX_RENDERED_MESSAGES + TRIM_LEEWAY) return
501
+ if (!this.nearBottom()) return
502
+
503
+ all.slice(0, all.length - MAX_RENDERED_MESSAGES).forEach((element) => element.remove())
504
+
505
+ // They're at the bottom of a 300+ message thread: any «new messages»
506
+ // divider is long since consumed.
507
+ this.element.querySelectorAll(".chats-thread__unread-line").forEach((line) => line.remove())
508
+
509
+ this.rebuildPaginationAnchor()
510
+ this.scheduleDaySeparators()
511
+ this.scheduleMessageGroups()
512
+ }
513
+
514
+ // Trimmed history must stay REACHABLE: drop pagination frames that no
515
+ // longer hold any bubbles (their lazy anchors point into ranges that no
516
+ // longer line up), then plant a fresh lazy frame anchored at the new
517
+ // oldest bubble — scrolling up re-fetches everything older, seamlessly,
518
+ // through the exact same keyset endpoint the original chain used.
519
+ rebuildPaginationAnchor() {
520
+ if (!this.hasScrollerTarget) return
521
+
522
+ this.scrollerTarget.querySelectorAll('turbo-frame[id^="chats_page_"]').forEach((frame) => {
523
+ if (!frame.querySelector('[data-chats--thread-target="message"]')) frame.remove()
524
+ })
525
+
526
+ const oldest = this.messageTargets[0]
527
+ if (!oldest || !this.threadUrlValue) return
528
+
529
+ // dom_id format is "chats_message_<id>" — the record id is the tail.
530
+ const messageId = oldest.id.split("_").pop()
531
+ const frameId = `chats_page_${messageId}`
532
+ if (!messageId || document.getElementById(frameId)) return
533
+
534
+ const frame = document.createElement("turbo-frame")
535
+ frame.id = frameId
536
+ frame.setAttribute("loading", "lazy")
537
+ const separator = this.threadUrlValue.includes("?") ? "&" : "?"
538
+ frame.src = `${this.threadUrlValue}${separator}before=${messageId}`
539
+ frame.innerHTML = '<div class="chats-loader"></div>'
540
+ this.scrollerTarget.prepend(frame)
541
+ }
542
+
543
+ // --- Long-press message popup (Telegram-style) ----------------------------------
544
+ //
545
+ // Long-press (or right-click) a bubble: a clone of it morphs to the center
546
+ // of the screen over a blurred glass backdrop, the reactions pill appears
547
+ // above it and the contextual menu (copy / edit / delete / host-injected
548
+ // items) below. NOTHING actionable renders inline on bubbles — the menu
549
+ // content lives in each bubble's inert <template data-chats-message-menu>
550
+ // and is cloned in here on open. The FLIP morph measures the bubble's
551
+ // on-screen rect, mounts the clone at its centered slot, then transitions
552
+ // the delta transform back to zero (and the reverse on close).
553
+
554
+ pressStart(event) {
555
+ if (this.popupOpenFor) return
556
+ if (event.button !== undefined && event.button !== 0) return
557
+
558
+ const bubble = event.target.closest(".chats-message")
559
+ if (!bubble || !this.element.contains(bubble)) return
560
+ // Buttons/fields keep their own press semantics, but LINKS must not
561
+ // block the gesture — an image-attachment bubble is one big <a>, and
562
+ // "long-press doesn't work on some messages" was exactly that. The
563
+ // release-click on a link after the popup opened is swallowed by the
564
+ // capture-phase suppressor installed in connect().
565
+ if (event.target.closest("button, input, textarea, summary")) return
566
+
567
+ this.pressOrigin = { x: event.clientX, y: event.clientY }
568
+ clearTimeout(this.pressTimer)
569
+ this.pressTimer = setTimeout(() => this.openPopup(bubble), LONG_PRESS_MS)
570
+ }
571
+
572
+ pressMove(event) {
573
+ if (!this.pressTimer || !this.pressOrigin) return
574
+ const dx = event.clientX - this.pressOrigin.x
575
+ const dy = event.clientY - this.pressOrigin.y
576
+ if (Math.hypot(dx, dy) > PRESS_MOVE_TOLERANCE_PX) this.cancelPress()
577
+ }
578
+
579
+ pressEnd() { this.cancelPress() }
580
+ pressCancel() { this.cancelPress() }
581
+
582
+ cancelPress() {
583
+ clearTimeout(this.pressTimer)
584
+ this.pressTimer = null
585
+ }
586
+
587
+ // Desktop parity + the Android long-press double-fire fix in one place:
588
+ // right-click on a bubble IS the popup (no 450ms hold), and the native
589
+ // context menu never appears over messages.
590
+ contextMenu(event) {
591
+ const bubble = event.target.closest(".chats-message")
592
+ if (!bubble) return
593
+
594
+ event.preventDefault()
595
+ this.cancelPress()
596
+ if (!this.popupOpenFor) this.openPopup(bubble)
597
+ }
598
+
599
+ openPopup(bubble) {
600
+ this.cancelPress()
601
+ if (!this.hasPopupTarget) return
602
+
603
+ const template = bubble.querySelector("template[data-chats-message-menu]")
604
+ const visual = bubble.querySelector(".chats-message__bubble") || bubble
605
+ if (!template) return
606
+
607
+ const own = bubble.dataset.senderKey === this.meValue
608
+ const content = template.content.cloneNode(true)
609
+ // Cosmetic visibility gates (the server still authorizes for real):
610
+ // own-only items (edit/delete) vanish on foreign bubbles; other-only
611
+ // items (e.g. a host report link — you don't report yourself) on own.
612
+ if (!own) content.querySelectorAll("[data-chats-own-only]").forEach((node) => node.remove())
613
+ if (own) content.querySelectorAll("[data-chats-other-only]").forEach((node) => node.remove())
614
+
615
+ const reactions = content.querySelector(".chats-popup__reactions")
616
+ const menu = content.querySelector(".chats-popup__menu")
617
+ this.popupReactionsTarget.replaceChildren(...(reactions ? [reactions] : []))
618
+ this.popupMenuTarget.replaceChildren(...(menu ? [menu] : []))
619
+
620
+ // The lifted bubble: a visual clone (templates stripped) that morphs
621
+ // from the original's rect into the centered stack slot.
622
+ const originRect = visual.getBoundingClientRect()
623
+ const clone = visual.cloneNode(true)
624
+ clone.querySelectorAll("template").forEach((node) => node.remove())
625
+ clone.classList.add("chats-popup__bubble")
626
+ // The clone must LOOK like the original: same width (bubbles size to
627
+ // content — letting the slot re-wrap them reads as a different element),
628
+ // and the own-context class goes on the SLOT so every descendant rule
629
+ // keyed on `.chats-message--own …` (gem CSS and host ancestor variants
630
+ // alike) still applies inside the popup.
631
+ clone.style.width = `${originRect.width}px`
632
+ // Mount INVISIBLE: between insertion and the FLIP transform a frame can
633
+ // paint, flashing the clone at its slot position before it "jumps" home
634
+ // — the visible half of the reported jank. The original bubble stays
635
+ // visible meanwhile, and the two swap in a single frame below.
636
+ clone.style.visibility = "hidden"
637
+ this.popupBubbleTarget.classList.toggle("chats-message--own", own)
638
+ this.popupBubbleTarget.replaceChildren(clone)
639
+
640
+ this.popupOpenFor = bubble
641
+ this.popupVisual = visual
642
+ this.popupTarget.hidden = false
643
+ this.suppressNextClick = true
644
+
645
+ // Anchor the stack to the bubble's OWN side — own messages stay pinned
646
+ // right, others stay pinned left at their exact x. Telegram never drags
647
+ // a message to the horizontal center; only the vertical position changes
648
+ // (to make room for the pill above and the menu below).
649
+ const stack = this.popupBubbleTarget.parentElement
650
+ const viewportWidth = document.documentElement.clientWidth
651
+ const gutter = 12
652
+ stack.classList.toggle("chats-popup__stack--own", own)
653
+ if (own) {
654
+ stack.style.left = "auto"
655
+ stack.style.right = `${Math.max(viewportWidth - originRect.right, gutter)}px`
656
+ } else {
657
+ stack.style.right = "auto"
658
+ stack.style.left = `${Math.max(originRect.left, gutter)}px`
659
+ }
660
+ stack.style.maxWidth = `${viewportWidth - gutter * 2}px`
661
+
662
+ const targetRect = clone.getBoundingClientRect()
663
+ clone.style.transform =
664
+ `translate(${originRect.left - targetRect.left}px, ${originRect.top - targetRect.top}px)`
665
+
666
+ // One frame: clone appears exactly over the original as the original
667
+ // hides — no gap, no double image. Next frame starts the morph.
668
+ requestAnimationFrame(() => {
669
+ clone.style.visibility = ""
670
+ visual.classList.add("chats-message--lifted")
671
+ requestAnimationFrame(() => {
672
+ this.popupTarget.classList.add("chats-popup--open")
673
+ clone.style.transform = ""
674
+ })
675
+ })
676
+
677
+ this.popupKeydown = (event) => {
678
+ if (event.key === "Escape") this.closePopup()
679
+ }
680
+ document.addEventListener("keydown", this.popupKeydown)
681
+ }
682
+
683
+ closePopup() {
684
+ const bubble = this.popupOpenFor
685
+ if (!bubble) return
686
+ this.popupOpenFor = null
687
+
688
+ const clone = this.popupBubbleTarget.firstElementChild
689
+ const visual = this.popupVisual
690
+ // Reverse FLIP against the bubble's CURRENT rect — the thread may have
691
+ // appended/scrolled underneath while the popup was open.
692
+ if (clone && visual) {
693
+ const originRect = visual.getBoundingClientRect()
694
+ const targetRect = clone.getBoundingClientRect()
695
+ clone.style.transform =
696
+ `translate(${originRect.left - targetRect.left}px, ${originRect.top - targetRect.top}px)`
697
+ }
698
+ this.popupTarget.classList.remove("chats-popup--open")
699
+ this.suppressNextClick = false
700
+
701
+ clearTimeout(this.popupCloseTimer)
702
+ this.popupCloseTimer = setTimeout(() => this.teardownPopup(visual), POPUP_TRANSITION_MS)
703
+ document.removeEventListener("keydown", this.popupKeydown)
704
+ }
705
+
706
+ teardownPopup(visual = this.popupVisual) {
707
+ if (!this.hasPopupTarget) return
708
+ this.popupTarget.hidden = true
709
+ this.popupTarget.classList.remove("chats-popup--open")
710
+ this.popupReactionsTarget.replaceChildren()
711
+ this.popupBubbleTarget.replaceChildren()
712
+ this.popupBubbleTarget.classList.remove("chats-message--own")
713
+ this.popupMenuTarget.replaceChildren()
714
+ const stack = this.popupBubbleTarget.parentElement
715
+ if (stack) {
716
+ stack.classList.remove("chats-popup__stack--own")
717
+ stack.style.left = ""
718
+ stack.style.right = ""
719
+ stack.style.maxWidth = ""
720
+ }
721
+ visual?.classList?.remove("chats-message--lifted")
722
+ this.popupVisual = null
723
+ }
724
+
725
+ popupMenuClicked(event) {
726
+ const item = event.target.closest("[data-chats-action], form button, form input[type=submit]")
727
+ if (!item) return
728
+
729
+ const action = item.dataset.chatsAction
730
+ if (action === "copy") {
731
+ event.preventDefault()
732
+ this.copyMessage(item)
733
+ } else if (action === "edit") {
734
+ event.preventDefault()
735
+ this.beginEditFromPopup()
736
+ } else {
737
+ // A real form submission (reaction toggle, delete): let it fire, then
738
+ // get out of the way — Telegram closes on selection too. The cloned
739
+ // form's request has already started by the time we tear down.
740
+ setTimeout(() => this.closePopup(), 60)
741
+ }
742
+ }
743
+
744
+ copyMessage(item) {
745
+ const text = this.pressedMessageBody()
746
+ if (!text) return this.closePopup()
747
+
748
+ this.writeClipboard(text).then((copied) => {
749
+ if (copied && this.copiedLabelValue) {
750
+ item.textContent = this.copiedLabelValue
751
+ setTimeout(() => this.closePopup(), 450)
752
+ } else {
753
+ this.closePopup()
754
+ }
755
+ })
756
+ }
757
+
758
+ // Clipboard order matters, and it's the reverse of what you'd expect:
759
+ // execCommand("copy") FIRST, synchronously, while we are still inside the
760
+ // user-gesture call stack — then navigator.clipboard as the fallback.
761
+ // Two WebKit realities force this (measured on iOS 26 WKWebView, 2026-06):
762
+ // * navigator.clipboard.writeText can reject in embedded WebViews (and
763
+ // doesn't exist at all on insecure dev origins), and
764
+ // * by the time its rejection handler runs (a microtask), WebKit has
765
+ // dropped the transient user-activation token — so a fallback
766
+ // execCommand inside .then()/.catch() silently copies NOTHING.
767
+ // Running the legacy path inside the original tap frame works on iOS
768
+ // WKWebView, Android WebView, and every desktop browser today; the async
769
+ // API only needs to carry contexts that have removed execCommand.
770
+ // https://developer.mozilla.org/docs/Web/API/Clipboard/writeText#security_considerations
771
+ // https://webkit.org/blog/10855/async-clipboard-api/ (gesture requirements)
772
+ writeClipboard(text) {
773
+ if (this.legacyClipboard(text)) return Promise.resolve(true)
774
+ if (navigator.clipboard?.writeText) {
775
+ return navigator.clipboard.writeText(text).then(() => true, () => false)
776
+ }
777
+ return Promise.resolve(false)
778
+ }
779
+
780
+ legacyClipboard(text) {
781
+ const area = document.createElement("textarea")
782
+ area.value = text
783
+ area.setAttribute("readonly", "")
784
+ area.style.position = "fixed"
785
+ area.style.opacity = "0"
786
+ document.body.appendChild(area)
787
+ area.focus()
788
+ area.select()
789
+ let copied = false
790
+ try {
791
+ copied = document.execCommand("copy")
792
+ } catch {
793
+ copied = false
794
+ }
795
+ area.remove()
796
+ return copied
797
+ }
798
+
799
+ // The pressed message's body text, with line breaks intact. innerText
800
+ // (not textContent) is what preserves the rendered paragraph breaks — but
801
+ // innerText of a HIDDEN element is "", and the original bubble is exactly
802
+ // the node the open popup hides (.chats-message--lifted). So read the
803
+ // popup's visible CLONE first — it carries the same marked body node —
804
+ // and only fall back to the original (e.g. mid-teardown).
805
+ // https://developer.mozilla.org/docs/Web/API/HTMLElement/innerText
806
+ pressedMessageBody() {
807
+ const selector = "[data-chats-message-body], .chats-message__text"
808
+ const source =
809
+ (this.hasPopupBubbleTarget && this.popupBubbleTarget.querySelector(selector)) ||
810
+ this.popupOpenFor?.querySelector(selector)
811
+ return source?.innerText?.trim() || ""
812
+ }
813
+
814
+ // Edit happens in the COMPOSER (Telegram's flow): close the popup (the
815
+ // bubble morphs back home) and hand the body off via a DOM event the
816
+ // chats--composer controller listens for — it shows the "edit message"
817
+ // quote cue and re-targets its form at the message's update URL.
818
+ beginEditFromPopup() {
819
+ const bubble = this.popupOpenFor
820
+ if (!bubble) return
821
+ const body = this.pressedMessageBody()
822
+ const messageId = bubble.id.split("_").pop()
823
+
824
+ this.closePopup()
825
+ window.dispatchEvent(new CustomEvent("chats:edit-message", {
826
+ detail: { id: messageId, body: body }
827
+ }))
828
+ }
829
+
830
+ // --- Chronology guard -------------------------------------------------------------
831
+
832
+ // Broadcast appends from concurrent host jobs can land out of order.
833
+ // ISO8601 strings compare lexicographically = chronologically; equal
834
+ // timestamps (same-second bursts) keep arrival order (stable).
835
+ ensureChronological(element) {
836
+ const timestamp = element.dataset.timestamp
837
+ if (!timestamp) return false
838
+
839
+ const newerThan = (node) =>
840
+ node?.classList?.contains("chats-message") && node.dataset.timestamp > timestamp
841
+
842
+ let anchor = element.previousElementSibling
843
+ if (!newerThan(anchor)) return false
844
+
845
+ while (newerThan(anchor.previousElementSibling)) {
846
+ anchor = anchor.previousElementSibling
847
+ }
848
+ anchor.parentElement.insertBefore(element, anchor)
849
+ return true
850
+ }
851
+ }
852
+
853
+ function csrfToken() {
854
+ return document.querySelector('meta[name="csrf-token"]')?.content || ""
855
+ }