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.
- checksums.yaml +7 -0
- data/.rubocop.yml +43 -0
- data/.simplecov +52 -0
- data/AGENTS.md +5 -0
- data/Appraisals +17 -0
- data/CHANGELOG.md +74 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/chats.css +818 -0
- data/app/controllers/chats/application_controller.rb +65 -0
- data/app/controllers/chats/conversations_controller.rb +198 -0
- data/app/controllers/chats/messages_controller.rb +118 -0
- data/app/controllers/chats/reactions_controller.rb +33 -0
- data/app/helpers/chats/engine_helper.rb +212 -0
- data/app/javascript/chats/composer_controller.js +258 -0
- data/app/javascript/chats/debounced_submit_controller.js +40 -0
- data/app/javascript/chats/thread_controller.js +855 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
- data/app/views/chats/conversations/_messages_page.html.erb +16 -0
- data/app/views/chats/conversations/_read_state.html.erb +11 -0
- data/app/views/chats/conversations/index.html.erb +54 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
- data/app/views/chats/conversations/show.html.erb +137 -0
- data/app/views/chats/messages/_composer.html.erb +67 -0
- data/app/views/chats/messages/_message.html.erb +158 -0
- data/app/views/chats/messages/create.turbo_stream.erb +6 -0
- data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
- data/app/views/chats/shared/_unread_badge.html.erb +6 -0
- data/config/importmap.rb +16 -0
- data/config/locales/en.yml +87 -0
- data/config/locales/es.yml +87 -0
- data/config/routes.rb +24 -0
- data/docs/PRD.md +254 -0
- data/docs/campfire_review.md +46 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/chats/broadcasts.rb +147 -0
- data/lib/chats/configuration.rb +286 -0
- data/lib/chats/engine.rb +146 -0
- data/lib/chats/errors.rb +20 -0
- data/lib/chats/macros.rb +28 -0
- data/lib/chats/models/application_record.rb +11 -0
- data/lib/chats/models/concerns/chat_subject.rb +35 -0
- data/lib/chats/models/concerns/messager.rb +102 -0
- data/lib/chats/models/conversation.rb +347 -0
- data/lib/chats/models/message.rb +323 -0
- data/lib/chats/models/participant.rb +151 -0
- data/lib/chats/models/reaction.rb +70 -0
- data/lib/chats/version.rb +5 -0
- data/lib/chats.rb +188 -0
- data/lib/generators/chats/install_generator.rb +62 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
- data/lib/generators/chats/templates/initializer.rb +138 -0
- data/lib/generators/chats/views_generator.rb +49 -0
- 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
|
+
}
|