Files
once-campfire/app/javascript/models/message_paginator.js
Kevin McConnell df76a227dc Hello world
First open source release of Campfire 🎉
2025-08-21 09:31:59 +01:00

197 lines
4.9 KiB
JavaScript

import { get } from "@rails/request.js"
import {
insertHTMLFragment,
parseHTMLFragment,
keepScroll,
trimChildren,
} from "helpers/dom_helpers"
import { ThreadStyle } from "models/message_formatter"
const MAX_MESSAGES = 300
const MAX_MESSAGES_LEEWAY = 20
class ScrollTracker {
#container
#callback
#intersectionObserver
#mutationObserver
#firstChildWasHidden
constructor(container, callback) {
this.#container = container
this.#callback = callback
this.#intersectionObserver = new IntersectionObserver(this.#handleIntersection.bind(this), { root: container })
this.#mutationObserver = new MutationObserver(this.#childrenChanged.bind(this))
this.#mutationObserver.observe(container, { childList: true })
}
connect() {
this.#childrenChanged()
}
disconnect() {
this.#intersectionObserver.disconnect()
}
#childrenChanged() {
this.disconnect()
if (this.#container.firstElementChild) {
this.#firstChildWasHidden = false
this.#intersectionObserver.observe(this.#container.firstElementChild)
this.#intersectionObserver.observe(this.#container.lastElementChild)
}
}
#handleIntersection(entries) {
for (const entry of entries) {
// Don't callback when the first child is shown, unless it had previously
// been hidden. This avoids the issue that adding new pages will always
// fire the callback for the first item before the scroll position is
// adjusted.
//
// We don't do this with the last item, because it's possible that
// fetching a page could return less than a screenfull.
const isFirst = entry.target === this.#container.firstElementChild
const significantReveal = (isFirst && this.#firstChildWasHidden) || !isFirst
if (entry.isIntersecting) {
if (significantReveal) {
this.#callback(entry.target)
}
} else {
if (isFirst) {
this.#firstChildWasHidden = true
}
}
}
}
}
export default class MessagePaginator {
#container
#url
#messageFormatter
#allContentViewedCallback
#scrollTracker
#upToDate = true
constructor(container, url, messageFormatter, allContentViewedCallback) {
this.#container = container
this.#url = url
this.#messageFormatter = messageFormatter
this.#allContentViewedCallback = allContentViewedCallback
this.#scrollTracker = new ScrollTracker(container, this.#messageBecameVisible.bind(this))
}
// API
monitor() {
this.#scrollTracker.connect()
}
disconnect() {
this.#scrollTracker.disconnect()
}
get upToDate() {
return this.#upToDate
}
set upToDate(value) {
this.#upToDate = value
}
async resetToLastPage() {
this.upToDate = true
await this.#showLastPage()
}
async trimExcessMessages(top) {
const overage = this.#container.children.length - MAX_MESSAGES
if (overage > MAX_MESSAGES_LEEWAY) {
trimChildren(overage, this.#container, top)
if (!top) {
this.upToDate = false
}
}
}
// Internal
#messageBecameVisible(element) {
const messageId = element.dataset.messageId
const firstMesage = element === this.#container.firstElementChild
const lastMessage = element === this.#container.lastElementChild
if (messageId) {
if (firstMesage) {
this.#addPage({ before: messageId }, true)
}
if (lastMessage && !this.upToDate) {
this.#addPage({ after: messageId }, false)
}
if (lastMessage && this.upToDate) {
this.#allContentViewedCallback?.()
}
}
}
async #showLastPage() {
const resp = await this.#fetchPage()
if (resp.statusCode === 200) {
const page = await this.#formatPage(resp)
this.#container.replaceChildren(page)
}
}
async #addPage(params, top) {
const resp = await this.#fetchPage(params)
if (resp.statusCode === 204 && !top) {
this.upToDate = true
this.#allContentViewedCallback?.()
}
if (resp.statusCode === 200) {
const page = await this.#formatPage(resp)
const lastNewElement = page.lastElementChild
keepScroll(this.#container, top, () => {
insertHTMLFragment(page, this.#container, top)
// Ensure formatting is correct over page boundaries
if (top && lastNewElement?.nextElementSibling) {
this.#messageFormatter.format(lastNewElement.nextElementSibling, ThreadStyle.thread)
}
})
this.trimExcessMessages(!top)
}
}
async #fetchPage(params) {
const url = new URL(this.#url)
for (const param in params) {
url.searchParams.set(param, params[param])
}
return await get(url)
}
async #formatPage(response) {
const text = await response.html
const fragment = parseHTMLFragment(text)
for (const message of fragment.querySelectorAll(".message")) {
this.#messageFormatter.format(message, ThreadStyle.thread)
}
return fragment
}
}