Hello world

First open source release of Campfire 🎉
This commit is contained in:
Kevin McConnell
2025-08-15 11:02:42 +01:00
commit df76a227dc
664 changed files with 36235 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
const EMOJI_MATCHER = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F)+$/gu
const SOUND_NAMES = [ "56k", "ballmer", "bell", "bezos", "bueller", "butts", "clowntown", "cottoneyejoe", "crickets", "curb", "dadgummit", "dangerzone", "danielsan", "deeper", "donotwant", "drama", "flawless", "glados", "gogogo", "greatjob", "greyjoy", "guarantee", "heygirl", "honk", "horn", "horror", "inconceivable", "letitgo", "live", "loggins", "makeitso", "noooo", "nyan", "ohmy", "ohyeah", "pushit", "rimshot", "rollout", "rumble", "sax", "secret", "sexyback", "story", "tada", "tmyk", "totes", "trololo", "trombone", "unix", "vuvuzela", "what", "whoomp", "wups", "yay", "yeah", "yodel" ]
export default class ClientMessage {
#template
constructor(template) {
this.#template = template
}
render(clientMessageId, node) {
const now = new Date()
const body = this.#contentFromNode(node)
return this.#createFromTemplate({
clientMessageId,
body,
messageTimestamp: Math.floor(now.getTime()),
messageDatetime: now.toISOString(),
messageClasses: this.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
})
}
update(clientMessageId, body) {
const element = this.#findWithId(clientMessageId).querySelector(".message__body-content")
if (element) {
element.innerHTML = body
}
}
failed(clientMessageId) {
const element = this.#findWithId(clientMessageId)
if (element) {
element.classList.add("message--failed")
}
}
#findWithId(clientMessageId) {
return document.querySelector(`#message_${clientMessageId}`)
}
#contentFromNode(node) {
if (this.#isPlayCommand(node)) {
return `<span class="pending">Playing ${this.#matchPlayCommand(node)}…</span>`
} else if (this.#isRichText(node)) {
return this.#richTextContent(node)
} else {
return node
}
}
#isPlayCommand(node) {
return this.#matchPlayCommand(node)
}
#matchPlayCommand(node) {
return this.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
}
#stripWrapperElement(node) {
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
}
#isRichText(node) {
return typeof(node) != "string"
}
#richTextContent(node) {
return `<div class="trix-content">${node.innerHTML}</div>`
}
#createFromTemplate(data) {
let html = this.#template.innerHTML
for (const key in data) {
html = html.replaceAll(`$${key}$`, data[key])
}
return html
}
#containsOnlyEmoji(text) {
return text?.match(EMOJI_MATCHER)
}
}

View File

@@ -0,0 +1,41 @@
export default class FileUploader {
constructor(file, url, clientMessageId, progressCallback) {
this.file = file
this.url = url
this.clientMessageId = clientMessageId
this.progressCallback = progressCallback
}
upload() {
const formdata = new FormData()
formdata.append("message[attachment]", this.file)
formdata.append("message[client_message_id]", this.clientMessageId)
const req = new XMLHttpRequest()
req.open("POST", this.url)
req.setRequestHeader("X-CSRF-Token", document.querySelector("meta[name=csrf-token]").content)
req.upload.addEventListener("progress", this.#uploadProgress.bind(this))
const result = new Promise((resolve, reject) => {
req.addEventListener("readystatechange", () => {
if (req.readyState === XMLHttpRequest.DONE) {
if (req.status < 400) {
resolve(req.response)
} else {
reject()
}
}
})
})
req.send(formdata)
return result
}
#uploadProgress(event) {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100)
this.progressCallback(percent, this.clientMessageId, this.file)
}
}
}

View File

@@ -0,0 +1,95 @@
import { onNextEventLoopTick } from "helpers/timing_helpers"
const THREADING_TIME_WINDOW_MILLISECONDS = 5 * 60 * 1000 // 5 minutes
export const ThreadStyle = {
none: 0,
thread: 1,
}
export default class MessageFormatter {
#userId
#classes
#dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "short" })
constructor(userId, classes) {
this.#userId = userId
this.#classes = classes
}
format(message, threadstyle) {
this.#setMeClass(message)
this.#highlightMentions(message)
if (threadstyle != ThreadStyle.none) {
this.#threadMessage(message)
this.#setFirstOfDayClass(message)
}
this.#makeVisible(message)
}
formatBody(body) {
this.#highlightCode(body)
}
#setMeClass(message) {
const isMe = message.dataset.userId == this.#userId
message.classList.toggle(this.#classes.me, isMe)
}
#makeVisible(message) {
message.classList.add(this.#classes.formatted)
}
#setFirstOfDayClass(message) {
let showSeparator = true
if (message.dataset.messageTimestamp && message.previousElementSibling?.dataset?.messageTimestamp) {
const prev = new Date(Number(message.previousElementSibling.dataset.messageTimestamp))
const curr = new Date(Number(message.dataset.messageTimestamp))
showSeparator = this.#dateFormatter.format(prev) !== this.#dateFormatter.format(curr)
}
message.classList.toggle(this.#classes.firstOfDay, showSeparator)
}
#threadMessage(message) {
if (message.previousElementSibling) {
const isSameUser = message.previousElementSibling.dataset.userId == message.dataset.userId
const previousMessageIsRecent = this.#previousMessageIsRecent(message)
message.classList.toggle(this.#classes.threaded, isSameUser && previousMessageIsRecent)
}
}
#highlightMentions(message) {
const mentionsCurrentUser = message.querySelector(this.#selectorForCurrentUser) !== null
message.classList.toggle(this.#classes.mentioned, mentionsCurrentUser)
}
#highlightCode(body) {
body.querySelectorAll("pre").forEach(block => {
onNextEventLoopTick(() => this.#highlightCodeBlock(block))
})
}
#highlightCodeBlock(block) {
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
}
#isPlainText(element) {
return Array.from(element.childNodes).every(node => node.nodeType === Node.TEXT_NODE)
}
#previousMessageIsRecent(message) {
const previousTimestamp = message.previousElementSibling.dataset.messageTimestamp
const threadTimestamp = message.dataset.messageTimestamp
return Math.abs(previousTimestamp - threadTimestamp) <= THREADING_TIME_WINDOW_MILLISECONDS
}
get #selectorForCurrentUser() {
return `.mention img[src^="/users/${Current.user.id}/avatar"]`
}
}

View File

@@ -0,0 +1,196 @@
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
}
}

View File

@@ -0,0 +1,57 @@
const AUTO_SCROLL_THRESHOLD = 100
export default class ScrollManager {
static #pendingOperations = Promise.resolve()
#container
constructor(container) {
this.#container = container
}
async autoscroll(forceScroll, render = () => {}) {
return this.#appendOperation(async () => {
const wasNearEnd = this.#scrolledNearEnd
await render()
if (wasNearEnd || forceScroll) {
this.#container.scrollTop = this.#container.scrollHeight
return true
} else {
return false
}
})
}
async keepScroll(top, render) {
return this.#appendOperation(async () => {
const scrollTop = this.#container.scrollTop
const scrollHeight = this.#container.scrollHeight
await render()
if (top) {
this.#container.scrollTop = scrollTop + (this.#container.scrollHeight - scrollHeight)
} else {
this.#container.scrollTop = scrollTop
}
})
}
// Private
#appendOperation(operation) {
ScrollManager.#pendingOperations =
ScrollManager.#pendingOperations.then(operation)
return ScrollManager.#pendingOperations
}
get #scrolledNearEnd() {
return this.#distanceScrolledFromEnd <= AUTO_SCROLL_THRESHOLD
}
get #distanceScrolledFromEnd() {
return this.#container.scrollHeight - this.#container.scrollTop - this.#container.clientHeight
}
}

View File

@@ -0,0 +1,42 @@
const REFRESH_INTERVAL = 1000
const TYPING_TIMEOUT = 5000
export default class TypingTracker {
constructor(callback) {
this.callback = callback
this.currentlyTyping = {}
this.timer = setInterval(this.#refresh.bind(this), REFRESH_INTERVAL)
}
close() {
clearInterval(this.timer)
}
add(name) {
this.currentlyTyping[name] = Date.now()
this.#refresh()
}
remove(name) {
delete this.currentlyTyping[name]
this.#refresh()
}
#refresh() {
this.#purgeInactive()
const names = Object.keys(this.currentlyTyping).sort()
if (names.length > 0) {
this.callback(`${names.join(", ")}`)
} else {
this.callback(null)
}
}
#purgeInactive() {
const cutoff = Date.now() - TYPING_TIMEOUT
this.currentlyTyping = Object.fromEntries(
Object.entries(this.currentlyTyping).filter(([_name, timestamp]) => timestamp > cutoff)
)
}
}