mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-05-25 03:28:43 +09:00
Hello world
First open source release of Campfire 🎉
This commit is contained in:
91
app/javascript/models/client_message.js
Normal file
91
app/javascript/models/client_message.js
Normal 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)
|
||||
}
|
||||
}
|
||||
41
app/javascript/models/file_uploader.js
Normal file
41
app/javascript/models/file_uploader.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
app/javascript/models/message_formatter.js
Normal file
95
app/javascript/models/message_formatter.js
Normal 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"]`
|
||||
}
|
||||
}
|
||||
196
app/javascript/models/message_paginator.js
Normal file
196
app/javascript/models/message_paginator.js
Normal 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
|
||||
}
|
||||
}
|
||||
57
app/javascript/models/scroll_manager.js
Normal file
57
app/javascript/models/scroll_manager.js
Normal 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
|
||||
}
|
||||
}
|
||||
42
app/javascript/models/typing_tracker.js
Normal file
42
app/javascript/models/typing_tracker.js
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user