| 1 |
// <mt-chat-room>, the live half of the chat page. |
| 2 |
// |
| 3 |
// The server renders the room's first window, the composer, and every gate |
| 4 |
// around them. This element adds the three things a page load cannot give you: |
| 5 |
// messages arriving without a refresh, sending without leaving the page, and |
| 6 |
// removing one that a moderator or its author deleted. |
| 7 |
// |
| 8 |
// Everything it decides lives in chat/chat.logic.ts and chat/reconnect.logic.ts, |
| 9 |
// which have no DOM in them and are unit tested. What is left here is the |
| 10 |
// wiring: attributes in, elements out, one EventSource. |
| 11 |
// |
| 12 |
// With the script blocked the page is still a working room. The backlog is |
| 13 |
// server-rendered and the composer is a real form posting to the same endpoint, |
| 14 |
// which answers with JSON rather than a redirect, so the no-JS experience is |
| 15 |
// send-and-see-a-blob rather than send-and-nothing. That is the accepted floor: |
| 16 |
// chat without a client is not chat, and the gates that matter are all |
| 17 |
// server-side regardless. |
| 18 |
|
| 19 |
import { MtElement, define } from "./base.ts"; |
| 20 |
import { |
| 21 |
ChatLedger, |
| 22 |
MAX_MESSAGE_LEN, |
| 23 |
checkBody, |
| 24 |
mentionsViewer, |
| 25 |
newNonce, |
| 26 |
parseFrame, |
| 27 |
streamUrl, |
| 28 |
type IncomingMessage, |
| 29 |
} from "./chat/chat.logic.ts"; |
| 30 |
import { ReconnectPolicy } from "./chat/reconnect.logic.ts"; |
| 31 |
|
| 32 |
/** The five frames the server sends. Dispatch reads the tag inside the payload. */ |
| 33 |
const FRAME_EVENTS = ["message", "delete", "purge", "wipe", "gap"] as const; |
| 34 |
|
| 35 |
/** |
| 36 |
* How close to the bottom counts as "following the room", in pixels. |
| 37 |
* |
| 38 |
* Someone reading back through the log must not be yanked to the bottom every |
| 39 |
* time a message lands, and someone at the bottom must not have to chase it. |
| 40 |
* The tolerance covers the fractional scroll heights a zoomed page produces. |
| 41 |
*/ |
| 42 |
const FOLLOW_THRESHOLD_PX = 40; |
| 43 |
|
| 44 |
class ChatRoom extends MtElement { |
| 45 |
#slug = ""; |
| 46 |
#viewer: string | null = null; |
| 47 |
#viewerName: string | null = null; |
| 48 |
#moderator = false; |
| 49 |
#maxLength = MAX_MESSAGE_LEN; |
| 50 |
|
| 51 |
#log!: HTMLOListElement; |
| 52 |
#status!: HTMLParagraphElement; |
| 53 |
#empty: HTMLElement | null = null; |
| 54 |
|
| 55 |
#ledger!: ChatLedger; |
| 56 |
readonly #policy = new ReconnectPolicy(); |
| 57 |
#source: EventSource | null = null; |
| 58 |
#retry: ReturnType<typeof setTimeout> | null = null; |
| 59 |
|
| 60 |
/** |
| 61 |
* Cancels the listeners this element put on `window` and `document`. |
| 62 |
* |
| 63 |
* Those two outlive the element, so without this a room htmx swapped away |
| 64 |
* would still be woken by a tab becoming visible, and would open a stream |
| 65 |
* nothing is reading. That connection holds a slot against the per-user cap |
| 66 |
* until the tab closes, so the room the user actually moved to is the one |
| 67 |
* that gets refused. |
| 68 |
*/ |
| 69 |
readonly #listeners = new AbortController(); |
| 70 |
|
| 71 |
protected init(): void { |
| 72 |
const log = this.querySelector<HTMLOListElement>("#chat-log"); |
| 73 |
if (!log) return; // not the chat page's markup; nothing to enhance |
| 74 |
this.#log = log; |
| 75 |
|
| 76 |
this.#slug = this.dataset["slug"] ?? ""; |
| 77 |
this.#viewer = this.dataset["viewer"] ?? null; |
| 78 |
this.#viewerName = this.dataset["viewerName"] ?? null; |
| 79 |
this.#moderator = this.dataset["moderator"] === "true"; |
| 80 |
this.#maxLength = Number(this.dataset["maxLength"]) || MAX_MESSAGE_LEN; |
| 81 |
this.#ledger = new ChatLedger(Number(this.dataset["cursor"])); |
| 82 |
this.#empty = this.querySelector<HTMLElement>("#chat-empty"); |
| 83 |
|
| 84 |
// Built here rather than in the template: with the script blocked there is |
| 85 |
// no connection to have an opinion about, so an empty status line would be |
| 86 |
// markup that only ever says nothing. |
| 87 |
this.#status = document.createElement("p"); |
| 88 |
this.#status.className = "chat-status"; |
| 89 |
this.#status.setAttribute("role", "status"); |
| 90 |
this.#status.hidden = true; |
| 91 |
this.#log.after(this.#status); |
| 92 |
|
| 93 |
for (const message of this.#log.querySelectorAll<HTMLLIElement>(".chat-message")) { |
| 94 |
this.#decorate(message); |
| 95 |
} |
| 96 |
|
| 97 |
this.#composer()?.addEventListener("submit", (event) => { |
| 98 |
event.preventDefault(); |
| 99 |
void this.#send(); |
| 100 |
}); |
| 101 |
|
| 102 |
// Both events say something about this client, not about the server, so |
| 103 |
// honoring an accumulated backoff after either would leave the room looking |
| 104 |
// broken for up to a minute for no reason. |
| 105 |
const signal = this.#listeners.signal; |
| 106 |
addEventListener("online", () => this.#reconnectNow(), { signal }); |
| 107 |
document.addEventListener( |
| 108 |
"visibilitychange", |
| 109 |
() => { |
| 110 |
if (!document.hidden) this.#reconnectNow(); |
| 111 |
}, |
| 112 |
{ signal }, |
| 113 |
); |
| 114 |
|
| 115 |
this.#connect(); |
| 116 |
this.#scrollToEnd(); |
| 117 |
} |
| 118 |
|
| 119 |
/** |
| 120 |
* htmx swaps the page body, and a swapped-away element keeps its connection |
| 121 |
* open unless something closes it. A leaked SSE connection holds a slot |
| 122 |
* against the per-user cap, so the room refuses to open in the new tab the |
| 123 |
* user just moved to. |
| 124 |
*/ |
| 125 |
disconnectedCallback(): void { |
| 126 |
this.#close(); |
| 127 |
this.#listeners.abort(); |
| 128 |
if (this.#retry !== null) clearTimeout(this.#retry); |
| 129 |
} |
| 130 |
|
| 131 |
// The stream |
| 132 |
|
| 133 |
#connect(): void { |
| 134 |
this.#close(); |
| 135 |
const source = new EventSource(streamUrl(this.#slug, this.#ledger.cursor)); |
| 136 |
this.#source = source; |
| 137 |
|
| 138 |
source.addEventListener("open", () => { |
| 139 |
this.#policy.succeed(); |
| 140 |
this.#clearStatus(); |
| 141 |
}); |
| 142 |
|
| 143 |
for (const name of FRAME_EVENTS) { |
| 144 |
source.addEventListener(name, (event) => this.#frame(event as MessageEvent<string>)); |
| 145 |
} |
| 146 |
|
| 147 |
// EventSource reconnects on its own schedule, which is a fixed few seconds |
| 148 |
// with no jitter and no ceiling on attempts. Every open room drops at the |
| 149 |
// same instant on a deploy, so that default is the thundering herd the |
| 150 |
// backoff exists to prevent. Closing here takes the retry back. |
| 151 |
source.addEventListener("error", () => this.#dropped()); |
| 152 |
} |
| 153 |
|
| 154 |
#close(): void { |
| 155 |
this.#source?.close(); |
| 156 |
this.#source = null; |
| 157 |
} |
| 158 |
|
| 159 |
#dropped(): void { |
| 160 |
this.#close(); |
| 161 |
const action = this.#policy.fail(); |
| 162 |
if (action.kind === "give-up") { |
| 163 |
this.#offerReconnect(); |
| 164 |
return; |
| 165 |
} |
| 166 |
this.#say("Reconnecting..."); |
| 167 |
this.#retry = setTimeout(() => this.#connect(), action.delayMs); |
| 168 |
} |
| 169 |
|
| 170 |
/** Drop the accumulated wait and try again immediately. */ |
| 171 |
#reconnectNow(): void { |
| 172 |
if (this.#source !== null) return; |
| 173 |
if (this.#retry !== null) clearTimeout(this.#retry); |
| 174 |
this.#policy.resetForImmediateRetry(); |
| 175 |
this.#connect(); |
| 176 |
} |
| 177 |
|
| 178 |
#frame(event: MessageEvent<string>): void { |
| 179 |
const frame = parseFrame(event.data); |
| 180 |
if (frame === null) return; // one malformed frame must not kill the stream |
| 181 |
|
| 182 |
switch (frame.type) { |
| 183 |
case "message": |
| 184 |
this.#received(frame); |
| 185 |
break; |
| 186 |
case "delete": |
| 187 |
this.#remove(`[data-id="${CSS.escape(String(frame.id))}"]`); |
| 188 |
break; |
| 189 |
case "purge": |
| 190 |
this.#remove(`[data-author="${CSS.escape(frame.author_id)}"]`); |
| 191 |
break; |
| 192 |
case "wipe": |
| 193 |
this.#wipe(); |
| 194 |
break; |
| 195 |
case "gap": |
| 196 |
// The connection fell behind and the hub dropped frames for it. |
| 197 |
// Reopening at the cursor replays exactly the hole. |
| 198 |
this.#connect(); |
| 199 |
break; |
| 200 |
} |
| 201 |
} |
| 202 |
|
| 203 |
#received(message: IncomingMessage): void { |
| 204 |
const outcome = this.#ledger.receive(message); |
| 205 |
if (outcome.kind === "duplicate") return; |
| 206 |
|
| 207 |
const following = this.#atEnd(); |
| 208 |
|
| 209 |
if (outcome.kind === "reconcile") { |
| 210 |
const pending = this.#pending(outcome.nonce); |
| 211 |
if (pending) { |
| 212 |
pending.replaceWith(this.#render(message)); |
| 213 |
if (following) this.#scrollToEnd(); |
| 214 |
return; |
| 215 |
} |
| 216 |
// The placeholder is gone (the tab was swapped, the user dismissed a |
| 217 |
// failure). The message is still real, so show it. |
| 218 |
} |
| 219 |
|
| 220 |
this.#append(this.#render(message)); |
| 221 |
if (following) this.#scrollToEnd(); |
| 222 |
} |
| 223 |
|
| 224 |
// Sending |
| 225 |
|
| 226 |
async #send(): Promise<void> { |
| 227 |
const input = this.querySelector<HTMLInputElement>("#chat-input"); |
| 228 |
if (!input) return; |
| 229 |
|
| 230 |
const check = checkBody(input.value); |
| 231 |
if (check.kind === "empty") return; |
| 232 |
if (check.kind === "too-long") { |
| 233 |
this.#say(`Too long by ${check.length - this.#maxLength} characters.`); |
| 234 |
return; |
| 235 |
} |
| 236 |
|
| 237 |
const nonce = newNonce(); |
| 238 |
const placeholder = this.#renderPending(check.body, nonce); |
| 239 |
this.#ledger.track(nonce); |
| 240 |
this.#append(placeholder); |
| 241 |
this.#scrollToEnd(); |
| 242 |
input.value = ""; |
| 243 |
this.#clearStatus(); |
| 244 |
|
| 245 |
const outcome = await this.#post(check.body, nonce); |
| 246 |
if (outcome.ok) { |
| 247 |
// The echo does the un-greying, because it carries the rendered body and |
| 248 |
// the author as the room should show them. All this needs to do is make |
| 249 |
// the message addressable in case a delete frame arrives first. |
| 250 |
if (placeholder.isConnected) placeholder.dataset["id"] = String(outcome.id); |
| 251 |
return; |
| 252 |
} |
| 253 |
|
| 254 |
this.#ledger.untrack(nonce); |
| 255 |
this.#fail(placeholder, check.body, outcome.message); |
| 256 |
} |
| 257 |
|
| 258 |
async #post( |
| 259 |
body: string, |
| 260 |
nonce: string, |
| 261 |
): Promise<{ ok: true; id: number } | { ok: false; message: string }> { |
| 262 |
const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content; |
| 263 |
try { |
| 264 |
const response = await fetch(`/p/${encodeURIComponent(this.#slug)}/chat/send`, { |
| 265 |
method: "POST", |
| 266 |
headers: { |
| 267 |
"Content-Type": "application/x-www-form-urlencoded", |
| 268 |
...(token ? { "X-CSRF-Token": token } : {}), |
| 269 |
}, |
| 270 |
body: new URLSearchParams({ body, nonce }).toString(), |
| 271 |
}); |
| 272 |
|
| 273 |
if (!response.ok) { |
| 274 |
// Every refusal the send path can produce comes back as plain text |
| 275 |
// written for the sender: rate limits, mutes, bans, a room that just |
| 276 |
// went read-only. Showing it beats a generic failure. |
| 277 |
const message = (await response.text().catch(() => "")).trim(); |
| 278 |
return { ok: false, message: message || "Message not sent." }; |
| 279 |
} |
| 280 |
|
| 281 |
const sent = (await response.json()) as { id: number }; |
| 282 |
return { ok: true, id: sent.id }; |
| 283 |
} catch { |
| 284 |
return { ok: false, message: "Message not sent. Check your connection." }; |
| 285 |
} |
| 286 |
} |
| 287 |
|
| 288 |
/** Mark a placeholder as failed, with the text preserved and one way back. */ |
| 289 |
#fail(placeholder: HTMLLIElement, body: string, message: string): void { |
| 290 |
placeholder.classList.remove("is-pending"); |
| 291 |
placeholder.classList.add("is-failed"); |
| 292 |
delete placeholder.dataset["nonce"]; |
| 293 |
|
| 294 |
const retry = document.createElement("button"); |
| 295 |
retry.type = "button"; |
| 296 |
retry.className = "chat-retry"; |
| 297 |
retry.textContent = "Retry"; |
| 298 |
retry.addEventListener("click", () => { |
| 299 |
const input = this.querySelector<HTMLInputElement>("#chat-input"); |
| 300 |
if (input) { |
| 301 |
// Back into the composer rather than resent behind the user's back: by |
| 302 |
// the time a send fails the reason is usually something they need to |
| 303 |
// see, and a silent retry loop against a rate limit is how a client |
| 304 |
// earns a ban. |
| 305 |
input.value = body; |
| 306 |
input.focus(); |
| 307 |
} |
| 308 |
placeholder.remove(); |
| 309 |
}); |
| 310 |
|
| 311 |
const note = document.createElement("span"); |
| 312 |
note.className = "chat-error"; |
| 313 |
note.textContent = message; |
| 314 |
placeholder.append(note, retry); |
| 315 |
} |
| 316 |
|
| 317 |
// Rendering |
| 318 |
// |
| 319 |
// The markup below is the same shape templates/pages/chat.html renders, so |
| 320 |
// one set of CSS rules covers both and a message looks identical before and |
| 321 |
// after a reload. |
| 322 |
|
| 323 |
#render(message: IncomingMessage): HTMLLIElement { |
| 324 |
const li = document.createElement("li"); |
| 325 |
li.className = "chat-message"; |
| 326 |
li.dataset["id"] = String(message.id); |
| 327 |
li.dataset["author"] = message.author_id; |
| 328 |
|
| 329 |
const avatar = message.author?.avatar_url; |
| 330 |
if (avatar !== undefined) { |
| 331 |
const img = document.createElement("img"); |
| 332 |
img.className = "chat-avatar"; |
| 333 |
img.src = avatar; |
| 334 |
img.alt = ""; |
| 335 |
img.width = 24; |
| 336 |
img.height = 24; |
| 337 |
li.append(img); |
| 338 |
} |
| 339 |
|
| 340 |
const author = document.createElement("span"); |
| 341 |
author.className = "chat-author"; |
| 342 |
author.textContent = message.author?.display_name ?? "Unknown"; |
| 343 |
|
| 344 |
const body = document.createElement("span"); |
| 345 |
body.className = "chat-body"; |
| 346 |
// Rendered and sanitized server-side by docengine's chat preset, then |
| 347 |
// carried verbatim through the transport. The client never renders markdown |
| 348 |
// and never sanitizes: a second sanitizer would be a second policy to keep |
| 349 |
// in step with the first. |
| 350 |
body.innerHTML = message.body_html; |
| 351 |
|
| 352 |
li.append(author, this.#time(message.created_at), body); |
| 353 |
this.#decorate(li); |
| 354 |
return li; |
| 355 |
} |
| 356 |
|
| 357 |
/** The sender's own message, on screen before the server has confirmed it. */ |
| 358 |
#renderPending(body: string, nonce: string): HTMLLIElement { |
| 359 |
const li = document.createElement("li"); |
| 360 |
li.className = "chat-message is-pending"; |
| 361 |
li.dataset["nonce"] = nonce; |
| 362 |
if (this.#viewer !== null) li.dataset["author"] = this.#viewer; |
| 363 |
|
| 364 |
const author = document.createElement("span"); |
| 365 |
author.className = "chat-author"; |
| 366 |
author.textContent = this.#selfName(); |
| 367 |
|
| 368 |
const text = document.createElement("span"); |
| 369 |
text.className = "chat-body"; |
| 370 |
// The raw text the user typed, as text. What comes back from the server is |
| 371 |
// the rendered version, and this is replaced by it wholesale. |
| 372 |
text.textContent = body; |
| 373 |
|
| 374 |
li.append(author, this.#time(Math.floor(Date.now() / 1000)), text); |
| 375 |
return li; |
| 376 |
} |
| 377 |
|
| 378 |
/** |
| 379 |
* Fill in a timestamp. |
| 380 |
* |
| 381 |
* The server writes unix seconds into `datetime`, which is not a value the |
| 382 |
* attribute is allowed to hold, and leaves the text empty because only the |
| 383 |
* client knows the reader's timezone. Both are settled here: the attribute |
| 384 |
* becomes a real ISO instant and the text becomes a local clock time. |
| 385 |
*/ |
| 386 |
#time(seconds: number): HTMLTimeElement { |
| 387 |
const time = document.createElement("time"); |
| 388 |
time.className = "chat-time"; |
| 389 |
this.#fillTime(time, seconds); |
| 390 |
return time; |
| 391 |
} |
| 392 |
|
| 393 |
#fillTime(time: HTMLTimeElement, seconds: number): void { |
| 394 |
const at = new Date(seconds * 1000); |
| 395 |
time.dateTime = at.toISOString(); |
| 396 |
time.textContent = at.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); |
| 397 |
time.title = at.toLocaleString(); |
| 398 |
} |
| 399 |
|
| 400 |
/** |
| 401 |
* Add what the server could not: a readable timestamp, and a delete control |
| 402 |
* for a message this viewer is allowed to remove. |
| 403 |
* |
| 404 |
* The button is an affordance, never the check. The handler re-derives |
| 405 |
* authorship and moderator status server-side, so hiding it is a courtesy to |
| 406 |
* the honest and nothing more. |
| 407 |
*/ |
| 408 |
#decorate(message: HTMLLIElement): void { |
| 409 |
const time = message.querySelector<HTMLTimeElement>(".chat-time"); |
| 410 |
if (time) { |
| 411 |
const seconds = Number(time.dateTime); |
| 412 |
if (Number.isFinite(seconds) && seconds > 0) this.#fillTime(time, seconds); |
| 413 |
} |
| 414 |
|
| 415 |
// In-room highlight only, never a notification. Done here rather than at |
| 416 |
// render time so it covers the server-rendered backlog as well: someone |
| 417 |
// returning to the page should see they were named while they were away, |
| 418 |
// and nothing else is going to tell them. |
| 419 |
const body = message.querySelector<HTMLElement>(".chat-body"); |
| 420 |
if (body && mentionsViewer(body.innerHTML, this.#viewerName)) { |
| 421 |
message.classList.add("is-mention"); |
| 422 |
} |
| 423 |
|
| 424 |
const id = message.dataset["id"]; |
| 425 |
if (id === undefined) return; |
| 426 |
if (!this.#moderator && message.dataset["author"] !== this.#viewer) return; |
| 427 |
if (message.querySelector(".chat-remove")) return; |
| 428 |
|
| 429 |
const remove = document.createElement("button"); |
| 430 |
remove.type = "button"; |
| 431 |
remove.className = "chat-remove"; |
| 432 |
remove.title = "Delete this message"; |
| 433 |
remove.setAttribute("aria-label", "Delete this message"); |
| 434 |
remove.textContent = "x"; |
| 435 |
remove.addEventListener("click", () => void this.#delete(id)); |
| 436 |
message.append(remove); |
| 437 |
} |
| 438 |
|
| 439 |
/** |
| 440 |
* Ask the server to remove a message. |
| 441 |
* |
| 442 |
* Nothing happens to the DOM on success: the removal comes back as a `delete` |
| 443 |
* frame, which every reader in the room including this one acts on. Removing |
| 444 |
* it locally as well would race that frame for no benefit, and would show the |
| 445 |
* message gone in the one case where the server refused. |
| 446 |
*/ |
| 447 |
async #delete(id: string): Promise<void> { |
| 448 |
const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content; |
| 449 |
const body = new URLSearchParams(token ? { csrf_token: token } : {}).toString(); |
| 450 |
try { |
| 451 |
const response = await fetch( |
| 452 |
`/p/${encodeURIComponent(this.#slug)}/chat/messages/${encodeURIComponent(id)}/delete`, |
| 453 |
{ |
| 454 |
method: "POST", |
| 455 |
headers: { |
| 456 |
"Content-Type": "application/x-www-form-urlencoded", |
| 457 |
...(token ? { "X-CSRF-Token": token } : {}), |
| 458 |
}, |
| 459 |
body, |
| 460 |
}, |
| 461 |
); |
| 462 |
if (!response.ok) this.#say("That message could not be deleted."); |
| 463 |
} catch { |
| 464 |
this.#say("That message could not be deleted."); |
| 465 |
} |
| 466 |
} |
| 467 |
|
| 468 |
// Small helpers over the log |
| 469 |
|
| 470 |
#append(message: HTMLLIElement): void { |
| 471 |
this.#log.append(message); |
| 472 |
if (this.#empty) this.#empty.hidden = true; |
| 473 |
} |
| 474 |
|
| 475 |
#remove(selector: string): void { |
| 476 |
for (const gone of this.#log.querySelectorAll(selector)) gone.remove(); |
| 477 |
if (this.#empty && this.#log.childElementCount === 0) this.#empty.hidden = false; |
| 478 |
} |
| 479 |
|
| 480 |
/** |
| 481 |
* The owner emptied the room. |
| 482 |
* |
| 483 |
* Everything goes, including a message still waiting for its echo: the send |
| 484 |
* either landed before the wipe and was deleted with the rest, or lands after |
| 485 |
* it and arrives on the stream like any other message. Either way the ledger |
| 486 |
* is left alone — the cursor is a high-water mark over ids the server has |
| 487 |
* issued, and a wipe does not un-issue them. |
| 488 |
*/ |
| 489 |
#wipe(): void { |
| 490 |
this.#log.replaceChildren(); |
| 491 |
if (this.#empty) this.#empty.hidden = false; |
| 492 |
this.#say("The owner cleared the chat history."); |
| 493 |
} |
| 494 |
|
| 495 |
#pending(nonce: string): HTMLLIElement | null { |
| 496 |
return this.#log.querySelector<HTMLLIElement>(`[data-nonce="${CSS.escape(nonce)}"]`); |
| 497 |
} |
| 498 |
|
| 499 |
#composer(): HTMLFormElement | null { |
| 500 |
return this.querySelector<HTMLFormElement>("#chat-composer"); |
| 501 |
} |
| 502 |
|
| 503 |
/** |
| 504 |
* What to call the sender on their own optimistic message. |
| 505 |
* |
| 506 |
* Taken from the last message they sent in this room, falling back to "You". |
| 507 |
* The page does not carry a display name for the viewer, and the placeholder |
| 508 |
* is replaced by the server's own rendering within a round trip, so this is a |
| 509 |
* label with a short life and no consequences. |
| 510 |
*/ |
| 511 |
#selfName(): string { |
| 512 |
if (this.#viewer === null) return "You"; |
| 513 |
const mine = this.#log.querySelectorAll<HTMLLIElement>( |
| 514 |
`.chat-message[data-author="${CSS.escape(this.#viewer)}"] .chat-author`, |
| 515 |
); |
| 516 |
return mine[mine.length - 1]?.textContent?.trim() || "You"; |
| 517 |
} |
| 518 |
|
| 519 |
#atEnd(): boolean { |
| 520 |
const { scrollTop, scrollHeight, clientHeight } = this.#log; |
| 521 |
return scrollHeight - scrollTop - clientHeight <= FOLLOW_THRESHOLD_PX; |
| 522 |
} |
| 523 |
|
| 524 |
#scrollToEnd(): void { |
| 525 |
this.#log.scrollTop = this.#log.scrollHeight; |
| 526 |
} |
| 527 |
|
| 528 |
#say(message: string): void { |
| 529 |
this.#status.textContent = message; |
| 530 |
this.#status.hidden = false; |
| 531 |
} |
| 532 |
|
| 533 |
#clearStatus(): void { |
| 534 |
this.#status.textContent = ""; |
| 535 |
this.#status.hidden = true; |
| 536 |
} |
| 537 |
|
| 538 |
/** Backoff exhausted: stop retrying into the void and hand it to the user. */ |
| 539 |
#offerReconnect(): void { |
| 540 |
this.#clearStatus(); |
| 541 |
this.#status.textContent = "Disconnected. "; |
| 542 |
const button = document.createElement("button"); |
| 543 |
button.type = "button"; |
| 544 |
button.className = "chat-retry"; |
| 545 |
button.textContent = "Reconnect"; |
| 546 |
button.addEventListener("click", () => this.#reconnectNow()); |
| 547 |
this.#status.append(button); |
| 548 |
this.#status.hidden = false; |
| 549 |
} |
| 550 |
} |
| 551 |
|
| 552 |
define("mt-chat-room", ChatRoom); |
| 553 |
|