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,6 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "trix"
import "@rails/actiontext"
import "initializers"
import "controllers"

View File

@@ -0,0 +1,8 @@
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }

View File

@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.requestSubmit()
}
}

View File

@@ -0,0 +1,48 @@
import { Controller } from "@hotwired/stimulus"
import AutocompleteHandler from "lib/autocomplete/autocomplete_handler"
import { debounce } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "select", "input" ]
static values = { url: String }
#handler
initialize() {
this.search = debounce(this.search.bind(this), 300)
}
connect() {
this.#installHandler()
this.inputTarget.focus()
}
disconnect() {
this.#uninstallHandler()
}
search(event) {
this.#handler.search(event.target.value)
}
didPressKey(event) {
if (event.key == "Backspace" && this.inputTarget.value == "") {
this.#handler.removeLastSelection()
}
}
remove(event) {
this.#handler.remove(event.target.closest("button").dataset.value)
this.inputTarget.focus()
}
#installHandler() {
this.#uninstallHandler()
this.#handler = new AutocompleteHandler(this.inputTarget, this.selectTarget, this.urlValue)
}
#uninstallHandler() {
this.#handler?.disconnect()
this.#handler?.destroy()
}
}

View File

@@ -0,0 +1,31 @@
import { Controller } from "@hotwired/stimulus"
import { onNextEventLoopTick } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "unread" ]
static classes = [ "unread" ]
connect() {
onNextEventLoopTick(() => this.update())
}
update() {
if (this.#available) {
const unreadCount = this.#unreadCount
if (unreadCount > 0) {
navigator.setAppBadge(unreadCount)
} else {
navigator.clearAppBadge()
}
}
}
get #unreadCount() {
return this.unreadTargets.filter(unreadTarget => unreadTarget.classList.contains(this.unreadClass)).length
}
get #available() {
return "setAppBadge" in navigator
}
}

View File

@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static classes = [ "reveal", "perform" ]
static targets = [ "button", "content" ]
static values = { boosterId: Number }
connect() {
if (this.#currentUserIsBooster) {
this.#setAccessibleAttributes()
}
}
reveal() {
if (this.#currentUserIsBooster) {
this.element.classList.toggle(this.revealClass)
this.buttonTarget.focus()
}
}
perform() {
this.element.classList.add(this.performClass)
}
#setAccessibleAttributes() {
this.contentTarget.setAttribute('tabindex', '0')
this.contentTarget.setAttribute('aria-describedby', 'delete_boost_accessible_label')
}
get #currentUserIsBooster() {
return Current.user.id === this.boosterIdValue
}
}

View File

@@ -0,0 +1,194 @@
import { Controller } from "@hotwired/stimulus"
import FileUploader from "models/file_uploader"
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
import { escapeHTML } from "helpers/dom_helpers"
export default class extends Controller {
static classes = ["toolbar"]
static targets = [ "clientid", "fields", "fileList", "text" ]
static values = { roomId: Number }
static outlets = [ "messages" ]
#files = []
connect() {
if (!this.#usingTouchDevice) {
onNextEventLoopTick(() => this.textTarget.focus())
}
}
submit(event) {
event.preventDefault()
if (!this.fieldsTarget.disabled) {
this.#submitFiles()
this.#submitMessage()
this.collapseToolbar()
this.textTarget.focus()
}
}
submitEnd(event) {
if (!event.detail.success) {
this.messagesOutlet.failPendingMessage(this.clientidTarget.value)
}
}
toggleToolbar() {
this.element.classList.toggle(this.toolbarClass)
this.textTarget.focus()
}
collapseToolbar() {
this.element.classList.remove(this.toolbarClass)
}
replaceMessageContent(content) {
const editor = this.textTarget.editor
editor.recordUndoEntry("Format reply")
editor.setSelectedRange([0, editor.getDocument().toString().length])
editor.deleteInDirection("forward")
editor.insertHTML(content)
editor.setSelectedRange([editor.getDocument().toString().length - 1])
}
submitByKeyboard(event) {
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey)
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
this.submit(event)
}
}
filePicked(event) {
for (const file of event.target.files) {
this.#files.push(file)
}
event.target.value = null
this.#updateFileList()
}
fileUnpicked(event) {
this.#files.splice(event.params.index, 1)
this.#updateFileList()
}
pasteFiles(event) {
if (event.clipboardData.files.length > 0) {
event.preventDefault()
}
for (const file of event.clipboardData.files) {
this.#files.push(file)
}
this.#updateFileList()
}
dropFiles({ detail: { files } }) {
for (const file of files) {
this.#files.push(file)
}
this.#updateFileList()
}
preventAttachment(event) {
event.preventDefault()
}
online() {
this.fieldsTarget.disabled = false
}
offline() {
this.fieldsTarget.disabled = true
}
get #usingTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}
async #submitMessage() {
if (this.#validInput()) {
const clientMessageId = this.#generateClientId()
await this.messagesOutlet.insertPendingMessage(clientMessageId, this.textTarget)
await nextFrame()
this.clientidTarget.value = clientMessageId
this.element.requestSubmit()
this.#reset()
}
}
#validInput() {
return this.textTarget.textContent.trim().length > 0
}
async #submitFiles() {
const files = this.#files
this.#files = []
this.#updateFileList()
for (const file of files) {
const clientMessageId = this.#generateClientId()
const uploader = new FileUploader(file, this.element.action, clientMessageId, this.#uploadProgress.bind(this))
const body = this.#pendingUploadProgress(file.name)
await this.messagesOutlet.insertPendingMessage(clientMessageId, body)
const resp = await uploader.upload()
Turbo.renderStreamMessage(resp)
}
}
#uploadProgress(percent, clientMessageId, file) {
const body = this.#pendingUploadProgress(file.name, percent)
this.messagesOutlet.updatePendingMessage(clientMessageId, body)
}
#generateClientId() {
return Math.random().toString(36).slice(2)
}
#reset() {
this.textTarget.value = ""
}
#updateFileList() {
this.#files.sort((a, b) => a.name.localeCompare(b.name))
const fileNodes = this.#files.map((file, index) => {
const filename = file.name.split(".").slice(0, -1).join(".")
const extension = file.name.split(".").pop()
const node = document.createElement("button")
node.setAttribute("type","button")
node.setAttribute("style","gap: 0")
node.dataset.action = "composer#fileUnpicked"
node.dataset.composerIndexParam = index
node.className = "btn btn--plain composer__file txt-normal position-relative unpad flex-column"
node.innerHTML = file.type.match(/^image\/.*/) ? `<img role="presentation" class="flex-item-no-shrink composer__file-thumbnail" src="${URL.createObjectURL(file)}">` : `<span class="composer__file-thumbnail composer__file-thumbnail--common colorize--black"></span>`
node.innerHTML += `<span class="pad-inline txt-small flex align-center max-width composer__file-caption"><span class="overflow-ellipsis">${escapeHTML(filename)}.</span><span class="flex-item-no-shrink">${escapeHTML(extension)}</span></span>`
return node
})
this.fileListTarget.replaceChildren(...fileNodes)
}
#pendingUploadProgress(filename, percent=0) {
return `
<div class="message__pending-upload flex align-center gap" style="--percentage: ${percent}%">
<div class="composer__file-thumbnail composer__file-thumbnail--common colorize--black borderless flex-item-no-shrink"></div>
<div>${escapeHTML(filename)} - <span>${percent}%</span></div>
</div>
`
}
}

View File

@@ -0,0 +1,25 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { content: String }
static classes = [ "success" ]
async copy(event) {
event.preventDefault()
this.reset()
try {
await navigator.clipboard.writeText(this.contentValue)
this.element.classList.add(this.successClass)
} catch {}
}
reset() {
this.element.classList.remove(this.successClass)
this.#forceReflow()
}
#forceReflow() {
this.element.offsetWidth
}
}

View File

@@ -0,0 +1,17 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
dragenter(event) {
event.preventDefault()
}
dragover(event) {
event.preventDefault()
event.dataTransfer.dropEffect = "copy"
}
drop(event) {
event.preventDefault()
this.dispatch("drop", { detail: { files: event.dataTransfer.files }})
}
}

View File

@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
remove() {
this.element.remove()
}
}

View File

@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
log(event) {
console.log(event)
}
}

View File

@@ -0,0 +1,46 @@
import { Controller } from "@hotwired/stimulus"
import { debounce } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "list" ]
static classes = [ "active", "selected" ]
initialize() {
this.filter = debounce(this.filter.bind(this), 300)
}
connect() {
this.element.focus()
}
filter(event) {
this.#reset()
if (event.target.value != "") {
this.#selectMatches(event.target.value)
this.#activate()
}
}
#reset() {
this.#deactivate()
this.listTarget.querySelectorAll(`.${this.selectedClass}`).forEach((element) => {
element.classList.remove(this.selectedClass)
})
}
#activate() {
this.listTarget.classList.add(this.activeClass)
}
#deactivate() {
this.listTarget.classList.remove(this.activeClass)
}
#selectMatches(value) {
this.listTarget.querySelectorAll(`[data-value*=${value.toLowerCase()}]`).forEach((element) => {
element.classList.add(this.selectedClass)
})
}
}

View File

@@ -0,0 +1,17 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "cancel" ]
submit() {
this.element.requestSubmit()
}
cancel() {
this.cancelTarget?.click()
}
preventAttachment(event) {
event.preventDefault()
}
}

View File

@@ -0,0 +1,4 @@
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

View File

@@ -0,0 +1,24 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "image", "dialog", "zoomedImage", "download", "share" ]
open(event) {
event.preventDefault()
this.dialogTarget.showModal()
this.#set(event.target.closest("a"))
}
reset() {
this.zoomedImageTarget.src = ""
this.downloadTarget.href = ""
this.shareTarget.dataset.webShareFilesValue = "";
}
#set(target) {
this.zoomedImageTarget.src = target.href
this.downloadTarget.href = target.dataset.lightboxUrlValue;
this.shareTarget.dataset.webShareFilesValue = target.dataset.lightboxUrlValue;
}
}

View File

@@ -0,0 +1,29 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "time", "date", "datetime" ]
initialize() {
this.timeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short" })
this.dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "long" })
this.dateTimeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", dateStyle: "short" })
}
timeTargetConnected(target) {
this.#formatTime(this.timeFormatter, target)
}
dateTargetConnected(target) {
this.#formatTime(this.dateFormatter, target)
}
datetimeTargetConnected(target) {
this.#formatTime(this.dateTimeFormatter, target)
}
#formatTime(formatter, target) {
const dt = new Date(target.getAttribute("datetime"))
target.textContent = formatter.format(dt)
target.title = this.dateTimeFormatter.format(dt)
}
}

View File

@@ -0,0 +1,32 @@
import { Controller } from "@hotwired/stimulus"
import ScrollManager from "models/scroll_manager"
export default class extends Controller {
#scrollManager
connect() {
this.#scrollManager = new ScrollManager(this.element)
}
// Actions
beforeStreamRender(event) {
const shouldKeepScroll = event.detail.newStream.hasAttribute("maintain_scroll")
const render = event.detail.render
const target = event.detail.newStream.getAttribute("target")
const targetElement = document.getElementById(target)
if (this.element.contains(targetElement) && shouldKeepScroll) {
const top = this.#isAboveFold(targetElement)
event.detail.render = async (streamElement) => {
this.#scrollManager.keepScroll(top, () => render(streamElement))
}
}
}
// Internal
#isAboveFold(element) {
return element.getBoundingClientRect().top < this.element.clientHeight
}
}

View File

@@ -0,0 +1,190 @@
import { Controller } from "@hotwired/stimulus"
import { nextEventLoopTick } from "helpers/timing_helpers"
import ClientMessage from "models/client_message"
import MessageFormatter, { ThreadStyle } from "models/message_formatter"
import MessagePaginator from "models/message_paginator"
import ScrollManager from "models/scroll_manager"
export default class extends Controller {
static targets = [ "latest", "message", "body", "messages", "template" ]
static classes = [ "firstOfDay", "formatted", "me", "mentioned", "threaded" ]
static values = { pageUrl: String }
#clientMessage
#paginator
#formatter
#scrollManager
// Lifecycle
initialize() {
this.#formatter = new MessageFormatter(Current.user.id, {
firstOfDay: this.firstOfDayClass,
formatted: this.formattedClass,
me: this.meClass,
mentioned: this.mentionedClass,
threaded: this.threadedClass,
})
}
connect() {
this.#clientMessage = new ClientMessage(this.templateTarget)
this.#paginator = new MessagePaginator(this.messagesTarget, this.pageUrlValue, this.#formatter, this.#allContentViewed.bind(this))
this.#scrollManager = new ScrollManager(this.messagesTarget)
if (this.#hasSearchResult) {
this.#highlightSearchResult()
} else {
this.#scrollManager.autoscroll(true)
}
this.#paginator.monitor()
}
disconnect() {
this.#paginator.disconnect()
}
messageTargetConnected(target) {
this.#formatter.format(target, ThreadStyle.thread)
}
bodyTargetConnected(target) {
this.#formatter.formatBody(target)
}
// Actions
async beforeStreamRender(event) {
const target = event.detail.newStream.getAttribute("target")
if (target === this.messagesTarget.id) {
const render = event.detail.render
const upToDate = this.#paginator.upToDate
if (upToDate) {
event.detail.render = async (streamElement) => {
const didScroll = await this.#scrollManager.autoscroll(false, async () => {
await render(streamElement)
await nextEventLoopTick()
this.#positionLastMessage()
this.#playSoundForLastMessage()
this.#paginator.trimExcessMessages(true)
})
if (!didScroll) {
this.latestTarget.hidden = false
}
}
} else {
this.latestTarget.hidden = false
}
}
}
async returnToLatest() {
this.latestTarget.hidden = true
await this.#ensureUpToDate()
this.#scrollManager.autoscroll(true)
}
async editMyLastMessage() {
const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty")
if (editorEmpty && this.#paginator.upToDate) {
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
}
}
// Outlet actions
async insertPendingMessage(clientMessageId, node) {
await this.#ensureUpToDate()
return this.#scrollManager.autoscroll(true, async () => {
const message = this.#clientMessage.render(clientMessageId, node)
this.messagesTarget.insertAdjacentHTML("beforeend", message)
})
}
updatePendingMessage(clientMessageId, body) {
this.#clientMessage.update(clientMessageId, body)
}
failPendingMessage(clientMessageId) {
this.#clientMessage.failed(clientMessageId)
}
// Callbacks
#allContentViewed() {
this.latestTarget.hidden = true
}
// Internal
async #ensureUpToDate() {
if (!this.#paginator.upToDate) {
await this.#paginator.resetToLastPage()
}
}
#highlightSearchResult() {
const highlightId = location.pathname.split("@").pop()
const highlightMessage = this.messagesTarget.querySelector(`.message[data-message-id="${highlightId}"]`)
if (highlightMessage) {
highlightMessage.classList.add("search-highlight")
highlightMessage.scrollIntoView({ behavior: "instant", block: "center" })
}
this.#paginator.upToDate = false
}
get #hasSearchResult() {
return location.pathname.includes("@")
}
get #lastMessage() {
return this.messagesTarget.children[this.messagesTarget.children.length - 1]
}
get #myLastMessage() {
const myMessages = this.messagesTarget.querySelectorAll(`.${this.meClass}`)
return myMessages[myMessages.length - 1]
}
#positionLastMessage() {
const followingMessage = this.#followingMessage(this.#lastMessage)
if (followingMessage) {
followingMessage.before(this.#lastMessage)
}
}
#playSoundForLastMessage() {
const soundTarget = this.#lastMessage.querySelector(".sound")
if (soundTarget) {
this.dispatch("play", { target: soundTarget })
}
}
#followingMessage(message) {
const messageSortValue = this.#sortValue(message)
let followingMessage = null
let previousMessage = message.previousElementSibling
while (messageSortValue < this.#sortValue(previousMessage)) {
followingMessage = previousMessage
previousMessage = previousMessage.previousElementSibling;
}
return followingMessage
}
#sortValue(node) {
return (node && parseInt(node.dataset.sortValue)) || 0
}
}

View File

@@ -0,0 +1,165 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
import { pageIsTurboPreview } from "helpers/turbo_helpers"
import { onNextEventLoopTick } from "helpers/timing_helpers"
import { getCookie, setCookie } from "lib/cookie"
export default class extends Controller {
static values = { subscriptionsUrl: String }
static targets = [ "notAllowedNotice", "bell", "details" ]
static classes = [ "attention" ]
async connect() {
if (!pageIsTurboPreview()) {
if (window.notificationsPreviouslyReady) {
onNextEventLoopTick(() => this.dispatch("ready"))
} else {
const firstTimeReady = await this.isEnabled()
this.#pulseBellButton()
if (firstTimeReady) {
onNextEventLoopTick(() => this.dispatch("ready"))
window.notificationsPreviouslyReady = true
} else {
this.#showBellAlert()
}
}
}
}
async attemptToSubscribe() {
if (this.#allowed) {
const registration = await this.#serviceWorkerRegistration || await this.#registerServiceWorker()
switch(Notification.permission) {
case "denied": { this.#revealNotAllowedNotice(); break }
case "granted": { this.#subscribe(registration); break }
case "default": { this.#requestPermissionAndSubscribe(registration) }
}
} else {
this.#revealNotAllowedNotice()
}
this.#endFirstRun()
}
async isEnabled() {
if (this.#allowed) {
const registration = await this.#serviceWorkerRegistration
const existingSubscription = await registration?.pushManager?.getSubscription()
return Notification.permission == "granted" && registration && existingSubscription
} else {
return false
}
}
get #allowed() {
return navigator.serviceWorker && window.Notification
}
get #serviceWorkerRegistration() {
return navigator.serviceWorker.getRegistration(window.location.host)
}
#registerServiceWorker() {
return navigator.serviceWorker.register("/service-worker.js")
}
#revealNotAllowedNotice() {
this.notAllowedNoticeTarget.showModal()
this.#openSingleOption()
}
#openSingleOption() {
const visibleElements = this.detailsTargets.filter(item => !this.#isHidden(item))
if (visibleElements.length === 1) {
this.detailsTargets.forEach(item => item.toggleAttribute("open", item === visibleElements[0]))
}
}
#showBellAlert() {
this.bellTarget.querySelectorAll("img").forEach(img => img.toggleAttribute("hidden"))
}
#pulseBellButton() {
if (!this.#hasSeenFirstRun) {
this.bellTarget.classList.add(this.attentionClass)
}
}
#endFirstRun() {
this.bellTarget.classList.remove(this.attentionClass)
this.#markFirstRunSeen()
}
async #subscribe(registration) {
registration.pushManager
.subscribe({ userVisibleOnly: true, applicationServerKey: this.#vapidPublicKey })
.then(subscription => {
this.#syncPushSubscription(subscription)
this.dispatch("ready")
})
}
async #syncPushSubscription(subscription) {
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
if (!response.ok) subscription.unsubscribe()
}
async #requestPermissionAndSubscribe(registration) {
const permission = await Notification.requestPermission()
if (permission === "granted") this.#subscribe(registration)
}
get #vapidPublicKey() {
const encodedVapidPublicKey = document.querySelector('meta[name="vapid-public-key"]').content
return this.#urlBase64ToUint8Array(encodedVapidPublicKey)
}
get #hasSeenFirstRun() {
if (this.#isPWA) {
return getCookie("notifications-pwa-first-run-seen")
} else {
return getCookie("notifications-first-run-seen")
}
}
#markFirstRunSeen = (event) => {
if (this.#isPWA) {
setCookie("notifications-pwa-first-run-seen", true)
} else {
setCookie("notifications-first-run-seen", true)
}
}
#extractJsonPayloadAsString(subscription) {
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
}
// VAPID public key comes encoded as base64 but service worker registration needs it as a Uint8Array
#urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/")
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
#isHidden(item) {
return (item.offsetParent === null)
}
get #isPWA() {
return window.matchMedia("(display-mode: standalone)").matches
}
}

View File

@@ -0,0 +1,37 @@
import { Controller } from "@hotwired/stimulus"
const BOTTOM_THRESHOLD = 90
export default class extends Controller {
static targets = [ "menu" ]
static classes = [ "orientationTop" ]
close() {
this.element.open = false
}
toggle() {
this.#orient()
}
closeOnClickOutside({ target }) {
if (!this.element.contains(target)) this.close()
}
#orient() {
this.element.classList.toggle(this.orientationTopClass, this.#distanceToBottom < BOTTOM_THRESHOLD)
this.menuTarget.style.setProperty("--max-width", this.#maxWidth + "px")
}
get #distanceToBottom() {
return window.innerHeight - this.#boundingClientRect.bottom
}
get #maxWidth() {
return window.innerWidth - this.#boundingClientRect.left
}
get #boundingClientRect() {
return this.menuTarget.getBoundingClientRect()
}
}

View File

@@ -0,0 +1,83 @@
import { Controller } from "@hotwired/stimulus"
import { cable } from "@hotwired/turbo-rails"
import { delay, nextFrame } from "helpers/timing_helpers"
const REFRESH_INTERVAL = 50 * 1000 // 50 seconds
// We delay transmitting visibility changes to ignore brief periods of invisibility,
// like switching to another tab and back
const VISIBILITY_CHANGE_DELAY = 5000 // 5 seconds
export default class extends Controller {
async connect() {
this.channel = await cable.subscribeTo({ channel: "PresenceChannel", room_id: Current.room.id }, {
connected: this.#websocketConnected,
disconnected: this.#websocketDisconnected
})
this.wasVisible = true
await nextFrame()
this.dispatch("present", { detail: { roomId: Current.room.id } })
}
disconnect() {
this.#stopRefreshTimer()
this.channel?.unsubscribe()
}
visibilityChanged = () => {
if (this.#isVisible) {
this.#visible()
} else {
this.#hidden()
}
}
#websocketConnected = () => {
this.connected = true
this.#startRefreshTimer()
}
#websocketDisconnected = () => {
this.connected = false
this.#stopRefreshTimer()
}
#visible = async () => {
await delay(VISIBILITY_CHANGE_DELAY)
if (this.connected && this.#isVisible && !this.wasVisible) {
this.channel.send({ action: "present" })
this.#startRefreshTimer()
this.wasVisible = true
}
}
#hidden = async () => {
await delay(VISIBILITY_CHANGE_DELAY)
if (this.connected && this.wasVisible && !this.#isVisible) {
this.#stopRefreshTimer()
this.channel.send({ action: "absent" })
this.wasVisible = false
}
}
#startRefreshTimer = () => {
this.refreshTimer ??= setInterval(this.#refresh, REFRESH_INTERVAL)
}
#stopRefreshTimer = () => {
clearInterval(this.refreshTimer)
this.refreshTimer = null
}
#refresh = () => {
this.channel.send({ action: "refresh" })
}
get #isVisible() {
return document.visibilityState === "visible"
}
}

View File

@@ -0,0 +1,35 @@
import { Controller } from "@hotwired/stimulus"
import { getCookie, setCookie } from "lib/cookie"
export default class extends Controller {
static classes = [ "prompting" ]
connect() {
if (this.#canInstall && !this.#isInstalledPWA) {
window.addEventListener("beforeinstallprompt", this.#preventPrompt)
window.addEventListener("appinstalled", this.#installed)
}
}
promptInstall = () => {
this.deferredPrompt.prompt()
}
#installed = () => {
this.element.classList.remove(this.promptingClass)
}
#preventPrompt = (event) => {
event.preventDefault()
this.deferredPrompt = event;
this.element.classList.add(this.promptingClass)
}
get #canInstall() {
return "serviceWorker" in navigator
}
get #isInstalledPWA() {
return window.matchMedia("(display-mode: standalone)").matches
}
}

View File

@@ -0,0 +1,22 @@
import { Controller } from "@hotwired/stimulus"
import { cable } from "@hotwired/turbo-rails"
import { ignoringBriefDisconnects } from "helpers/dom_helpers"
export default class extends Controller {
async connect() {
this.channel ??= await cable.subscribeTo({ channel: "ReadRoomsChannel" }, {
received: this.#read
})
}
disconnect() {
ignoringBriefDisconnects(this.element, () => {
this.channel?.unsubscribe()
this.channel = null
})
}
#read = ({ room_id }) => {
this.dispatch("read", { detail: { roomId: room_id } })
}
}

View File

@@ -0,0 +1,75 @@
import { Controller } from "@hotwired/stimulus"
import { get } from "@rails/request.js"
import { cable } from "@hotwired/turbo-rails"
import { pageIsTurboPreview } from "helpers/turbo_helpers"
const OFFLINE_AFTER_DISCONNECTED_TIMEOUT = 5_000
const REFRESH_AFTER_HIDDEN_TIMEOUT = 60_000
export default class extends Controller {
static targets = [ "message" ]
static values = { loadedAt: Number, url: String, }
#lastLoadedAt
#offlineTimer = null
#hiddenAt = null
async connect() {
if (!pageIsTurboPreview()) {
this.#lastLoadedAt = this.loadedAtValue
this.#channelDisconnected()
this.channel = await cable.subscribeTo({ channel: "HeartbeatChannel" }, {
connected: this.#channelConnected.bind(this),
disconnected: this.#channelDisconnected.bind(this)
})
}
}
disconnect() {
this.channel?.unsubscribe()
}
messageTargetConnected(target) {
this.#lastLoadedAt = Math.max(this.#lastLoadedAt, target.dataset.messageUpdatedAt || 0)
}
visibilityChanged() {
if (document.visibilityState === "visible") {
if (this.#hiddenForTooLong()) {
this.#refresh("visibility")
this.dispatch("visible")
}
this.#hiddenAt = null
} else {
this.#hiddenAt = Date.now()
}
}
online() {
// Trigger reconnection attempt whenever the browser comes back
// from being offline
this.channel.consumer.connection.monitor.visibilityDidChange()
}
#channelConnected() {
this.#refresh("connection")
clearTimeout(this.#offlineTimer)
this.dispatch("online", { target: window })
}
#channelDisconnected() {
this.#offlineTimer = setTimeout(() => {
this.dispatch("offline", { target: window })
}, OFFLINE_AFTER_DISCONNECTED_TIMEOUT)
}
#refresh(reason) {
get(this.urlValue, { query: { since: this.#lastLoadedAt, reason: reason }, responseKind: "turbo-stream" })
}
#hiddenForTooLong() {
return this.#hiddenAt && Date.now() - this.#hiddenAt > REFRESH_AFTER_HIDDEN_TIMEOUT
}
}

View File

@@ -0,0 +1,48 @@
import { Controller } from "@hotwired/stimulus"
const unfurled_attachment_selector = ".og-embed"
export default class extends Controller {
static targets = [ "body", "link", "author" ]
static outlets = [ "composer" ]
connect() {
this.#formatLinkTargets()
}
reply() {
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><br>`
this.composerOutlet.replaceMessageContent(content)
}
#formatLinkTargets() {
this.bodyTarget.querySelectorAll("a").forEach(link => {
const sameDomain = link.href.startsWith(window.location.origin)
link.target = sameDomain ? "_top" : "_blank"
})
}
get #bodyContent() {
const body = this.bodyTarget.querySelector(".trix-content").cloneNode(true)
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
}
#stripMentionAttachments(node) {
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
return node
}
#stripUnfurledAttachments(node) {
const firstUnfurledLink = node.querySelector(`${unfurled_attachment_selector} a`)?.href
node.querySelectorAll(unfurled_attachment_selector).forEach(embed => embed.remove())
// Use unfurled link as the content when the node has no additional text
if (firstUnfurledLink && !node.textContent.trim()) node.textContent = firstUnfurledLink
return node
}
get #linkToOriginal() {
return `<a href="${this.linkTarget.href}">#</a>`
}
}

View File

@@ -0,0 +1,46 @@
import { Controller } from "@hotwired/stimulus"
import MentionsAutocompleteHandler from "lib/autocomplete/mentions_autocomplete_handler"
import { debounce } from "helpers/timing_helpers"
export default class extends Controller {
static values = { url: String }
initialize() {
this.handlers = []
this.search = debounce(this.search.bind(this), 300)
}
connect() {
if (this.element == document.activeElement) {
this.#installHandlers()
}
}
focus(event) {
this.#installHandlers()
}
search(event) {
const content = this.editor.getDocument().toString()
const position = this.editor.getPosition()
this.handlers.forEach(handler => handler.updateWithContentAndPosition(content, position))
}
blur(event) {
this.#uninstallHandlers()
}
#installHandlers() {
this.#uninstallHandlers()
this.handlers = [ new MentionsAutocompleteHandler(this.element, this.urlValue) ]
}
#uninstallHandlers() {
this.handlers.forEach(handler => handler.destroy())
this.handlers = []
}
get editor() {
return this.element.editor
}
}

View File

@@ -0,0 +1,65 @@
import { Controller } from "@hotwired/stimulus"
import { cable } from "@hotwired/turbo-rails"
import { ignoringBriefDisconnects } from "helpers/dom_helpers"
export default class extends Controller {
static targets = [ "room" ]
static classes = [ "unread" ]
#disconnected = true
async connect() {
this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, {
connected: this.#channelConnected.bind(this),
disconnected: this.#channelDisconnected.bind(this),
received: this.#unread.bind(this)
})
}
disconnect() {
ignoringBriefDisconnects(this.element, () => {
this.channel?.unsubscribe()
this.channel = null
})
}
loaded() {
this.read({ detail: { roomId: Current.room.id } })
}
read({ detail: { roomId } }) {
const room = this.#findRoomTarget(roomId)
if (room) {
room.classList.remove(this.unreadClass)
this.dispatch("read", { detail: { targetId: roomId } })
}
}
#channelConnected() {
if (this.#disconnected) {
this.#disconnected = false
this.element.reload()
}
}
#channelDisconnected() {
this.#disconnected = true
}
#unread({ roomId }) {
const unreadRoom = this.#findRoomTarget(roomId)
if (unreadRoom) {
if (Current.room.id != roomId) {
unreadRoom.classList.add(this.unreadClass)
}
this.dispatch("unread", { detail: { targetId: unreadRoom.id } })
}
}
#findRoomTarget(roomId) {
return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId)
}
}

View File

@@ -0,0 +1,9 @@
import { Controller } from "@hotwired/stimulus"
import { nextFrame } from "helpers/timing_helpers"
export default class extends Controller {
async connect() {
await nextFrame()
this.element.scrollIntoView({ behavior: "smooth", block: "center" })
}
}

View File

@@ -0,0 +1,26 @@
import { Controller } from "@hotwired/stimulus"
import MessageFormatter, { ThreadStyle } from "models/message_formatter"
export default class extends Controller {
static targets = [ "message" ]
static classes = [ "me", "threaded", "mentioned", "formatted" ]
#formatter
initialize() {
this.#formatter = new MessageFormatter(Current.user.id, {
formatted: this.formattedClass,
me: this.meClass,
mentioned: this.mentionedClass,
threaded: this.threadedClass,
})
}
connect() {
this.element.scrollTo({ top: this.element.scrollHeight })
}
messageTargetConnected(target) {
this.#formatter.format(target, ThreadStyle.none)
}
}

View File

@@ -0,0 +1,25 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "pushSubscriptionEndpoint" ]
async logout(event) {
await this.#unsubscribeFromWebPush()
this.element.requestSubmit()
}
async #unsubscribeFromWebPush() {
if ("serviceWorker" in navigator) {
const registration = await navigator.serviceWorker.getRegistration(window.location.host)
if (registration) {
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
this.pushSubscriptionEndpointTarget.value = subscription.endpoint
await subscription.unsubscribe()
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"
import { nextEventNamed } from "helpers/timing_helpers"
import { isTouchDevice } from "helpers/navigator_helpers"
export default class extends Controller {
static get shouldLoad() {
return isTouchDevice()
}
// Use a fake input to trigger the soft keyboard on actions that load async content
// See https://gist.github.com/cathyxz/73739c1bdea7d7011abb236541dc9aaa
async open(event) {
const fakeInput = this.#focusOnFakeInput()
this.#removeOnFocusOut(fakeInput)
}
#focusOnFakeInput() {
const fakeInput = document.createElement("input")
fakeInput.setAttribute("type", "text")
fakeInput.setAttribute("class", "input--invisible")
this.element.appendChild(fakeInput)
fakeInput.focus()
return fakeInput
}
async #removeOnFocusOut(element) {
await nextEventNamed("focusout", element)
element.remove()
}
}

View File

@@ -0,0 +1,36 @@
import { Controller } from "@hotwired/stimulus"
import { throttle } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "item" ]
itemTargetConnected(target) {
this.#throttledSort()
}
updateItem({ detail: { targetId }}) {
const itemTargetForUpdate = this.itemTargets.find(itemTarget => itemTarget.id == targetId)
if (itemTargetForUpdate) {
if (itemTargetForUpdate.dataset.sortedListNumber) {
itemTargetForUpdate.dataset.sortedListNumber = new Date().getTime()
}
this.sort()
}
}
sort() {
const sortedItemTargets = this.itemTargets.sort((a, b) => {
if (a.dataset.sortedListNumber) {
return b.dataset.sortedListNumber - a.dataset.sortedListNumber
} else {
return a.dataset.sortedListName.toLowerCase().localeCompare(b.dataset.sortedListName.toLowerCase())
}
})
sortedItemTargets.forEach(item => this.element.appendChild(item))
}
#throttledSort = throttle(this.sort.bind(this))
}

View File

@@ -0,0 +1,10 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { "url": String }
play() {
const sound = new Audio(this.urlValue)
sound.play()
}
}

View File

@@ -0,0 +1,9 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static classes = [ "toggle" ]
toggle() {
this.element.classList.toggle(this.toggleClass)
}
}

View File

@@ -0,0 +1,16 @@
import { Controller } from "@hotwired/stimulus"
import { onNextEventLoopTick } from "helpers/timing_helpers"
export default class extends Controller {
unpermanize() {
delete this.element.dataset.turboPermanent
}
reload() {
this.element.reload()
}
load({ params: { url }}) {
onNextEventLoopTick(() => this.element.src = url)
}
}

View File

@@ -0,0 +1,11 @@
import { Controller } from "@hotwired/stimulus"
// Unsubscribe a container from turbo streaming actions (by removing its id) can address timing jank
// when turbo streaming updates race against a full controller response.
export default class extends Controller {
static targets = [ "container" ]
unsubscribe() {
this.containerTarget.removeAttribute("id")
}
}

View File

@@ -0,0 +1,59 @@
import { Controller } from "@hotwired/stimulus"
import { cable } from "@hotwired/turbo-rails"
import { throttle } from "helpers/timing_helpers"
import { pageIsTurboPreview } from "helpers/turbo_helpers"
import TypingTracker from "models/typing_tracker"
export default class extends Controller {
static targets = [ "author", "indicator" ]
static classes = [ "active" ]
async connect() {
if (!pageIsTurboPreview()) {
this.tracker = new TypingTracker(this.#update.bind(this))
this.channel = await cable.subscribeTo(
{ channel: "TypingNotificationsChannel", room_id: Current.room.id },
{ received: this.#received.bind(this) }
)
}
}
disconnect() {
this.tracker?.close()
this.channel?.unsubscribe()
}
start({ target }) {
if (target.value) {
this.#throttledSend("start")
} else {
this.#send("stop")
}
}
stop() {
this.#send("stop");
}
#received({ action, user }) {
if (user.id !== Current.user.id) {
if (action === "start") {
this.tracker.add(user.name)
} else {
this.tracker.remove(user.name)
}
}
}
#send(action) {
this.channel.send({ action })
}
#update(message) {
this.authorTarget.textContent = message
this.indicatorTarget.classList.toggle(this.activeClass, !!message)
}
#throttledSend = throttle(action => this.#send(action))
}

View File

@@ -0,0 +1,14 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "image", "input" ]
previewImage() {
const file = this.inputTarget.files[0]
if (file) {
this.imageTarget.src = URL.createObjectURL(this.inputTarget.files[0]);
this.imageTarget.onload = () => { URL.revokeObjectURL(this.imageTarget.src) }
}
}
}

View File

@@ -0,0 +1,36 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { title: String, text: String, url: String, files: String }
connect() {
this.element.hidden = !navigator.canShare
}
async share() {
await navigator.share(await this.#getShareData())
}
async #getShareData() {
const data = { title: this.titleValue, text: this.textValue }
if (this.urlValue) {
data.url = this.urlValue
}
if (this.filesValue) {
data.files = [ await this.#getFileObject()]
}
return data;
}
async #getFileObject() {
const response = await fetch(this.filesValue)
const blob = await response.blob()
const randomPrefix = `Campfire_${Math.random().toString(36).slice(2)}`
const fileName = `${randomPrefix}.${blob.type.split('/').pop()}`
return new File([ blob ], fileName, { type: blob.type })
}
}

View File

@@ -0,0 +1,65 @@
export function scrollToBottom(container) {
container.scrollTop = container.scrollHeight
}
export function escapeHTML(html) {
const div = document.createElement("div")
div.textContent = html
return div.innerHTML
}
export function parseHTMLFragment(html) {
const template = document.createElement("template")
template.innerHTML = html
return template.content
}
export function insertHTMLFragment(fragment, container, top) {
if (top) {
container.prepend(fragment)
} else {
container.append(fragment)
}
}
export function ignoringBriefDisconnects(element, fn) {
requestAnimationFrame(() => {
if (!element.isConnected) fn()
})
}
export function trimChildren(count, container, top) {
const children = Array.from(container.children)
const elements = top ? children.slice(0, count) : children.slice(-count)
keepScroll(container, top, function() {
for (const element of elements) {
element.remove()
}
})
}
export async function keepScroll(container, top, fn) {
pauseInertiaScroll(container)
const scrollTop = container.scrollTop
const scrollHeight = container.scrollHeight
await fn()
if (top) {
container.scrollTop = scrollTop + (container.scrollHeight - scrollHeight)
} else {
container.scrollTop = scrollTop
}
}
function pauseInertiaScroll(container) {
container.style.overflow = "hidden"
requestAnimationFrame(() => {
container.style.overflow = ""
})
}

View File

@@ -0,0 +1,3 @@
export function isTouchDevice() {
return "ontouchstart" in window && navigator.maxTouchPoints > 0
}

View File

@@ -0,0 +1,7 @@
export function truncateString(string, length, omission = "…") {
if (string.length <= length) {
return string
} else {
return string.slice(0, length - omission.length) + omission
}
}

View File

@@ -0,0 +1,39 @@
export function throttle(fn, delay = 1000) {
let timeoutId = null
return (...args) => {
if (!timeoutId) {
fn(...args)
timeoutId = setTimeout(() => timeoutId = null, delay)
}
}
}
export function debounce(fn, delay = 1000) {
let timeoutId = null
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn.apply(this, args), delay)
}
}
export function nextEventLoopTick() {
return delay(0)
}
export function onNextEventLoopTick(callback) {
setTimeout(callback, 0)
}
export function nextFrame() {
return new Promise(requestAnimationFrame)
}
export function nextEventNamed(eventName, element = window) {
return new Promise(resolve => element.addEventListener(eventName, resolve, { once: true }))
}
export function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}

View File

@@ -0,0 +1,3 @@
export function pageIsTurboPreview() {
return document.documentElement.hasAttribute("data-turbo-preview")
}

View File

@@ -0,0 +1,5 @@
import SuggestionSelectElement from "lib/autocomplete/custom_elements/suggestion_select"
import SuggestionOptionElement from "lib/autocomplete/custom_elements/suggestion_option"
customElements.define("suggestion-select", SuggestionSelectElement)
customElements.define("suggestion-option", SuggestionOptionElement)

View File

@@ -0,0 +1,23 @@
class Current {
get user() {
const currentUserId = this.#extractContentFromMetaTag("current-user-id")
if (currentUserId) {
return { id: parseInt(currentUserId), name: this.#extractContentFromMetaTag("current-user-name") }
}
}
get room() {
const currentRoomId = this.#extractContentFromMetaTag("current-room-id")
if (currentRoomId) {
return { id: parseInt(currentRoomId) }
}
}
#extractContentFromMetaTag(name) {
return document.head.querySelector(`meta[name="${name}"]`)?.getAttribute("content")
}
}
window.Current = new Current()

View File

@@ -0,0 +1,29 @@
import hljs from "highlight.js"
import bash from "languages/bash"
import css from "languages/css"
import diff from "languages/diff"
import go from "languages/go"
import java from "languages/java"
import javascript from "languages/javascript"
import json from "languages/json"
import python from "languages/python"
import ruby from "languages/ruby"
import rust from "languages/rust"
import sql from "languages/sql"
import xml from "languages/xml"
hljs.registerLanguage("bash", bash)
hljs.registerLanguage("css", css)
hljs.registerLanguage("diff", diff)
hljs.registerLanguage("go", go)
hljs.registerLanguage("java", java)
hljs.registerLanguage("javascript", javascript)
hljs.registerLanguage("json", json)
hljs.registerLanguage("python", python)
hljs.registerLanguage("ruby", ruby)
hljs.registerLanguage("rust", rust)
hljs.registerLanguage("sql", sql)
hljs.registerLanguage("xml", xml)
window.hljs = hljs

View File

@@ -0,0 +1,4 @@
import "initializers/autocomplete"
import "initializers/current"
import "initializers/rich_text"
import "initializers/highlight"

View File

@@ -0,0 +1,10 @@
import Unfurler from "lib/rich_text/unfurl/unfurler"
// Support a `cite` block for attribution links
Trix.config.blockAttributes.cite = {
tagName: "cite",
inheritable: false,
}
const unfurler = new Unfurler()
unfurler.install()

View File

@@ -0,0 +1,46 @@
import BaseAutocompleteHandler from "lib/autocomplete/base_autocomplete_handler"
import Selection from "lib/autocomplete/selection"
export default class extends BaseAutocompleteHandler {
#selection
constructor(element, select, url) {
super(element, url)
this.#selection = new Selection(select)
}
disconnect() {
this.#selection.disconnect()
}
insertAutocompletable(autocompletable) {
this.#selection.add(autocompletable.value, autocompletable.name, { avatarUrl: autocompletable.avatar_url })
this.element.value = ""
}
get pattern() {
return new RegExp(`^(.*?)$`)
}
remove(value) {
this.#selection.remove(value)
}
removeLastSelection() {
this.#selection.removeLast()
}
search(term) {
super.updateWithContentAndPosition(term, 0)
}
setAutocompletables(autocompletables) {
super.setAutocompletables(this.#filterSelectedAutocompletables(autocompletables))
}
#filterSelectedAutocompletables(autocompletables) {
const selectedValues = this.#selection.values.concat(Current.user.id)
return autocompletables.filter(autocompletable => !selectedValues.includes(autocompletable.value))
}
}

View File

@@ -0,0 +1,128 @@
import Collection from "lib/autocomplete/collection"
import SuggestionController from "lib/autocomplete/suggestion_controller"
import { generateUUID } from "lib/autocomplete/helpers"
import { Renderer } from "lib/autocomplete/renderer"
export default class BaseAutocompleteHandler {
#autocompletables
#url
constructor(element, url) {
this.element = element
this.#url = url
if (!this.element.id) { this.element.id = `autocomplete_${generateUUID()}` }
this.suggestionController = new SuggestionController(this)
}
updateWithContentAndPosition(content, position) {
if (this.suggestionController && this.shouldAutocompleteWithContentAndPosition(content, position)) {
this.suggestionController.updateWithContentAndPosition(content, position)
}
}
destroy() {
this.#closeSuggestionController()
}
// Subclass methods
get pattern() {
return null
}
shouldAutocompleteWithContentAndPosition(content, position) {
return true
}
getAutocompletable(value) {
return this.#autocompletables.get(value)
}
autocompletablesMatchingQuery(query) {
return this.#autocompletables.matchingQuery(query).toArray()
}
loadAutocompletables(query, callback) {
const url = query ? this.#autocompletablesUrl(query) : this.#url
this.#fetchAutocompletables(url).then((autocompletables) => {
this.setAutocompletables(autocompletables)
callback()
})
}
setAutocompletables(autocompletables) {
this.#autocompletables = new Collection(autocompletables)
}
// SuggestionController Delegate
getSuggestionsIdentifier() {
return `${this.element.id}_suggestions`
}
matchQueryAndTerminatorForWord(word) {
if (!this.pattern) return
const match = word.match(this.pattern)
if (match) {
return {
query: match[1],
terminator: match?.[2] || ""
}
}
}
getOffsetsAtPosition(position) {
return this.element.getBoundingClientRect()
}
getResultsPlacement() {
return this.#suggestionResultsPlacement
}
fetchResultsForQuery(query, callback) {
this.loadAutocompletables(query, () => {
const autocompletables = this.autocompletablesMatchingQuery(query)
const html = new Renderer().renderAutocompletableSuggestions(autocompletables)
callback(html)
})
}
willCommitValueAtRangeWithTerminator(value, range, terminator) {
const autocompletable = this.getAutocompletable(value)
this.insertAutocompletable(autocompletable, range, terminator)
}
didShowResults(selectElement) {
selectElement.classList.add("rich_text")
}
#autocompletablesUrl(query) {
const separator = this.#url.includes('?') ? '&' : '?'
return `${this.#url}${separator}query=${query}`
}
get #suggestionResultsPlacement() {
return this.element.dataset.suggestionResultsPlacement
}
#closeSuggestionController() {
if (!this.suggestionController) return
if (this.suggestionController.active) {
this.suggestionController.hideResults()
} else {
this.suggestionController.destroy()
this.suggestionController = null
}
}
#fetchAutocompletables(url) {
if (url) {
return fetch(url, { as: "json" }).then(response => response.json())
} else {
return Promise.resolve()
}
}
}

View File

@@ -0,0 +1,134 @@
import { camelize, normalize, regexpForQuery, uniqueValues } from "lib/autocomplete/utils"
export default class AutocompletableCollection {
#autocompletables
#index
constructor(autocompletables = [], options = {}) {
this.#index = new Map()
this.#autocompletables = new Array()
Array.from(autocompletables).forEach((autocompletable) => {
this.#index.set(this.#uniqueAutocompleteableKey(autocompletable), autocompletable)
})
this.#index.forEach(autocompletable => {
this.#autocompletables.push(autocompletable)
})
if (options.sort !== false) {
this.#autocompletables.sort(this.#compareAutocompletables)
}
}
get(value) {
return this.#index.get(value.toString())
}
has(value) {
return this.#index.has(value.toString())
}
add(autocompletables = [], collectionOptions) {
return new this.constructor(this.#autocompletables.concat(autocompletables), collectionOptions)
}
getValues() {
return this.#autocompletables.map(autocompletable => autocompletable.value)
}
withValues(values = [], collectionOptions) {
const autocompletables = values.map((value) => this.get(value)).filter(Boolean)
return new this.constructor(autocompletables, collectionOptions)
}
withoutValues(values = [], collectionOptions) {
const autocompletables = []
this.#index.forEach(function(autocompletable, value) {
const allGroupMembersAreAdded = autocompletable.type == "group" && autocompletable.value.split(",").every(id => values.includes(id))
if (!values.includes(value) && !allGroupMembersAreAdded) {
return autocompletables.push(autocompletable)
}
})
return new this.constructor(autocompletables, collectionOptions)
}
filter(callback, collectionOptions) {
if (!callback) { return this }
const autocompletables = []
this.#index.forEach(function(autocompletable, value) {
if (callback(autocompletable)) {
return autocompletables.push(autocompletable)
}
})
return new this.constructor(autocompletables, collectionOptions)
}
matchingQuery(query) {
if (!query) { return this }
return new this.constructor(
this.#matchAutocompletablesByNameOrDescription(this.#autocompletables, query),
{ sort: false }
)
}
toArray() {
return this.#autocompletables.slice(0)
}
toJSON() {
return this.toArray()
}
isEqualTo(collection) {
if (!collection || (this.#autocompletables.length !== collection.length)) {
return false
}
return JSON.stringify(this) === JSON.stringify(collection)
}
#compareAutocompletables(autocompletable, otherAutocompletable) {
return autocompletable.name.localeCompare(otherAutocompletable.name)
}
#matchAutocompletablesByNameOrDescription(autocompletables, query) {
return uniqueValues([].concat(
this.#matchAutocompletablesByNameAtHead(autocompletables, query),
this.#matchAutocompletablesByRestOfName(autocompletables, query),
this.#matchAutocompletablesByRestOfDescription(autocompletables, query))
)
}
#matchAutocompletablesByNameAtHead(autocompletables, query) {
return this.#matchAutocompletablesByRegExp(autocompletables, regexpForQuery(query, "^"))
}
#matchAutocompletablesByRestOfName(autocompletables, query) {
return this.#matchAutocompletablesByRegExp(autocompletables, regexpForQuery(query, "\\s"))
}
#matchAutocompletablesByRestOfDescription(autocompletables, query) {
return this.#matchAutocompletablesByRegExp(autocompletables, regexpForQuery("", query), "description")
}
#matchAutocompletablesByRegExp(autocompletables, regexp, propertyName = "name") {
return autocompletables.filter(autocompletable => {
const normalizedPropertyName = `normalized${camelize(propertyName)}`
const property = autocompletable[propertyName]
if (property) {
if (!autocompletable[normalizedPropertyName]) autocompletable[normalizedPropertyName] = normalize(property)
return regexp.test(autocompletable[normalizedPropertyName])
}
})
}
#uniqueAutocompleteableKey(autocompletable) {
return autocompletable.value.toString()
}
}

View File

@@ -0,0 +1 @@
export const PUNCTUATION_PATTERN = /[\u0021-\u0023\u0025-\u002A\u002C-\u002F\u003A\u003B\u003F\u0040\u005B-\u005D\u005F\u007B\u007D\u00A1\u00A7\u00AB\u00B6\u00B7\u00BB\u00BF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E3B\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]/

View File

@@ -0,0 +1,59 @@
import { generateUUID, synchronize } from "lib/autocomplete/helpers"
export default class extends HTMLElement {
constructor() {
super(...arguments)
this.flash = synchronize(this.flash)
}
connectedCallback() {
this.id ||= `option-${generateUUID()}`
}
get selectElement() {
return this.closest("suggestion-select")
}
get index() {
if (this.selectElement) {
return Array.from(this.selectElement.optionElements).indexOf(this)
} else {
return null
}
}
get selected() {
return this.hasAttribute("selected")
}
set selected(value) {
if (value) {
this.setAttribute("selected", "")
} else {
this.removeAttribute("selected")
}
}
get value() {
return this.getAttribute("value")
}
flash(callback) {
const drawFrame = (frame = 0) => {
requestAnimationFrame(() => {
if (frame == 0) {
this.classList.add("flashing-off")
} else if (frame == 4) {
this.classList.remove("flashing-off")
}
if (frame == 7) {
callback()
} else {
drawFrame(frame + 1)
}
})
}
drawFrame(0)
}
}

View File

@@ -0,0 +1,44 @@
export default class extends HTMLElement {
connectedCallback() {
if (!this.hasAttribute("role")) this.setAttribute("role", "listbox")
}
get optionElements() {
return this.querySelectorAll("suggestion-option")
}
get selectedIndex() {
const selected = this.querySelector("suggestion-option[selected]")
return selected?.index
}
set selectedIndex(value) {
const optionElements = this.optionElements
const optionCount = optionElements.length
if (!optionElements.length) return
Array.from(optionElements).forEach(option => {
option.selected = false
})
if (value === null || typeof value === "undefined") return
const index = Math.max(0, Math.min(optionCount - 1, parseInt(value, 10)))
optionElements[index].selected = true
}
get selectedOption() {
return this.optionElements[this.selectedIndex]
}
set selectedOption(option) {
if (option.selectElement === this) {
this.selectedIndex = option.index
}
}
get value() {
return this.selectedOption?.value
}
}

View File

@@ -0,0 +1,103 @@
export function generateUUID() {
const template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"
return template.replace(/[xy]/g, function(char) {
const rand = (Math.random() * 16) | 0
const value = char === "x" ? rand : ((rand & 0x3)|0x8)
return value.toString(16)
})
}
export function synchronize(fn) {
const monitorCallbacks = new WeakMap
return function(callback) {
let callbacks = monitorCallbacks.get(this)
if (!callbacks) {
monitorCallbacks.set(this, (callbacks = []))
}
callbacks.push(callback)
if (callbacks.length === 1) {
return fn.call(this, () => {
Array.from(callbacks).forEach((callback) => { callback?.() })
return monitorCallbacks.delete(this)
})
}
}
}
export function transitionElementWithClass(element, className, callback) {
return applyClassAwaitingEvent(element, className, "transitionend", callback)
}
const applyClassAwaitingEvent = function(element, className, eventName, callback) {
let timeout
let uninstalled = false
const uninstall = function() {
if (!uninstalled) {
uninstalled = true
element.removeEventListener(eventName, uninstall)
return requestAnimationFrame(function() {
element.classList.remove(className)
return callback?.()
})
}
}
element.addEventListener(eventName, uninstall)
element.classList.add(className)
// Failsafe: If we don't receive a {transition,animation}end event
// for some reason, ensure that uninstall is still called.
const duration = getDuration(element, eventName)
if (duration) {
timeout = duration + 50
} else {
timeout = 50
}
return setTimeout(uninstall, timeout)
}
const getDuration = function(element, eventName) {
const type = eventName === "animationend" ? "animation" : "transition"
const duration = getComputedStyle(element)[`${type}Duration`]
if (duration) {
if (/ms/.test(duration)) {
return parseInt(duration, 10)
} else {
return parseFloat(duration) * 1000
}
}
}
export function getElementMargin(element) {
const result = {}
const style = window.getComputedStyle(element);
["Top", "Right", "Bottom", "Left"].forEach((side) => {
result[side.toLowerCase()] = parseInt(style[`margin${side}`], 10)
})
return result
}
export function getAbsolutePositionForOffsets({ top, right, bottom, left }) {
return {
top: top + window.scrollY,
right: right + window.scrollX,
bottom: bottom + window.scrollY,
left: left + window.scrollX
}
}
export function getViewportRect() {
return {
top: window.scrollY,
right: window.scrollX + window.innerWidth,
bottom: window.scrollY + window.innerHeight,
left: window.scrollX
}
}

View File

@@ -0,0 +1,48 @@
import BaseAutocompleteHandler from "lib/autocomplete/base_autocomplete_handler"
import { PUNCTUATION_PATTERN } from "lib/autocomplete/constants"
export default class extends BaseAutocompleteHandler {
get pattern() {
return new RegExp(`^@(.*?)(${PUNCTUATION_PATTERN.source}*)$`)
}
insertAutocompletable(autocompletable, range, terminator, options = {}) {
const attachment = this.#createAttachmentForAutocompletable(autocompletable)
this.#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range, options)
}
// Override to set selector's position relative to the cursor in the editor
getOffsetsAtPosition(position) {
return this.#getOffsetsFromEditorAtPosition(this.#editor, position)
}
#createAttachmentForAutocompletable(mentionable) {
const mention = `
<span class="mention" sgid=${mentionable.sgid}>
<img src="${mentionable.avatar_url}" class="avatar" alt="${mentionable.name}">
${mentionable.name}
</span>
`
return new Trix.Attachment({
content: mention,
contentType: "application/vnd.campfire.mention",
sgid: mentionable.sgid
})
}
#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range) {
if (range) { this.#editor.setSelectedRange(range) }
this.#editor.insertAttachment(attachment)
this.#editor.insertString(terminator)
}
get #editor() {
return this.element.editor
}
#getOffsetsFromEditorAtPosition(editor, position) {
const rect = this.#editor.getClientRectAtPosition(position)
return rect ? rect : {}
}
}

View File

@@ -0,0 +1,40 @@
export class Renderer {
renderAutocompletableSuggestions(autocompletables, options = {}) {
const { selectedAutocompletable } = options
let html = ""
autocompletables.forEach((autocompletable) => {
const isSelected = autocompletable === selectedAutocompletable
const multipleAttr = autocompletable.type === "group" ? "multiple" : ""
const selectedAriaSelectedAttrs = isSelected ? "selected aria-selected" : ""
html += `
<suggestion-option class="autocomplete__item flex align-center gap unpad" role="option" value="${autocompletable.value}" ${multipleAttr} ${selectedAriaSelectedAttrs}>
${
autocompletable.pending
? `Add <strong>${autocompletable.name}…</strong>`
: autocompletable.noResultsLabel
? `<span class="txt--disable-truncate">${autocompletable.noResultsLabel}</span>`
: this.renderAutocompletable(autocompletable)
}
</suggestion-option>
`
})
return html
}
renderAutocompletable(autocompletable) {
const html = `
<button class="autocomplete__btn btn btn--borderless btn--transparent min-width flex-item-grow justify-start" data-value="${autocompletable.value}">
<span class="avatar">
<img src="${autocompletable.avatar_url}" class="automcomplete__avatar" role="presentation" />
</span>
<span class="autocompletable__name">${autocompletable.name}</span>
<a href="#" class="autocompletable__unselect" aria-label="Remove ${autocompletable.name}" data-behavior="unselect_autocompletable">×</a>
</button>
`
return html
}
}

View File

@@ -0,0 +1,82 @@
import { memoize } from "lib/autocomplete/utils"
export default class Selection {
#elements = []
#element
#observer
constructor(element) {
this.#element = element
this.#observeMutations()
this.#render()
}
disconnect() {
this.#observer.disconnect()
this.#render()
}
add(value, label, options = {}) {
this.#element.append(this.#findOption(value) || this.#createOption(value, label, options))
}
remove(value) {
this.#findOption(value)?.remove()
}
removeLast() {
this.#lastOption?.remove()
}
get values() {
return this.#options.map(option => parseInt(option.value))
}
#createOption(value, label, { avatarUrl }) {
const option = new Option(label, value, true, true)
option.dataset.avatarUrl = avatarUrl
return option
}
#findOption(value) {
return this.#options.find(option => option.value == value)
}
get #lastOption() {
return this.#options.slice(-1)[0]
}
#observeMutations() {
this.#observer = new MutationObserver(this.#optionsChanged)
this.#observer.observe(this.#element, { childList: true })
}
#optionsChanged = () => {
this.#render()
}
get #options() {
return Array.from(this.#element.options)
}
#render() {
for (const element of this.#elements) element.remove()
this.#elements = this.#element.isConnected ? this.#options.map(this.#renderElementForOption) : []
}
#renderElementForOption = (option) => {
const { value, label } = option
const content = this.#template.content.cloneNode(true)
content.querySelectorAll("[data-value]").forEach(element => element.dataset.value = value)
content.querySelector("[data-content=label]").textContent = label
content.querySelector("[data-content=label]").title = value
content.querySelector("[data-content=screenReaderLabel]").textContent = label
content.querySelector("[data-content=avatar]").src = option.dataset.avatarUrl
return this.#template.insertAdjacentElement("beforebegin", content.firstElementChild)
}
get #template() {
const id = this.#element.getAttribute("data-template-id")
return memoize(this, "template", document.getElementById(id))
}
}

View File

@@ -0,0 +1,59 @@
export default class SuggestionContext {
#content
#position
constructor(delegate, content, position) {
this.#content = content
this.#position = position
const { matchQueryAndTerminatorForWord, characterMatchesWordBoundary } = delegate
const bounds = this.#findWordBoundsFromStringAtPosition(characterMatchesWordBoundary)
if (bounds) {
[this.startPosition, this.endPosition] = Array.from(bounds)
this.word = this.#content.slice(...Array.from(bounds || []))
const match = matchQueryAndTerminatorForWord(this.word)
if (match) {
const {query, terminator} = match
if (query.length) { this.query = query }
if (terminator.length) { this.terminator = terminator }
this.active = true
}
}
}
isActive() {
return this.active
}
isTerminated() {
return this.terminator?.length && (this.#position === this.endPosition)
}
isEqualTo(context) {
return false
}
#findWordBoundsFromStringAtPosition(characterMatchesWordBoundary) {
let char, index
let start = (index = this.#position)
while (--index >= 0) {
char = this.#content.charAt(index)
if (characterMatchesWordBoundary(char)) { break }
start = index
}
let end = (index = this.#position)
while (index < this.#content.length) {
char = this.#content.charAt(index)
if (characterMatchesWordBoundary(char)) { break }
end = ++index
}
if (start !== end) {
return [ start, end ]
}
}
}

View File

@@ -0,0 +1,349 @@
import SuggestionResultsController from "lib/autocomplete/suggestion_results_controller"
import SuggestionContext from "lib/autocomplete/suggestion_context"
export default class SuggestionController {
#active = false
#canceled = false
#committing = false
#context
#resultsController
constructor(delegate) {
this.delegate = delegate
this.commitSuggestion = this.commitSuggestion.bind(this)
this.characterMatchesWordBoundary = this.characterMatchesWordBoundary.bind(this)
this.matchQueryAndTerminatorForWord = this.matchQueryAndTerminatorForWord.bind(this)
this.didPressKey = this.didPressKey.bind(this)
this.didResizeWindow = this.didResizeWindow.bind(this)
this.didScrollWindow = this.didScrollWindow.bind(this)
this.#installKeyboardListener()
this.#installResizeListeners()
}
updateWithContentAndPosition(content, position) {
if (this.#committing) { return }
const previousContext = this.#context
this.#context = new SuggestionContext(this, content, position)
if (!this.#context.isEqualTo(previousContext)) {
if (this.#context.isTerminated()) {
return this.commitSuggestion()
} else if (this.#context.isActive()) {
return this.#activateSuggestion()
} else {
return this.#deactivateSuggestion()
}
}
}
hideResults() {
return this.#resultsController.hide()
}
destroy() {
this.#uninstallResultsController()
this.#uninstallResizeListeners()
this.#uninstallKeyboardListener()
}
commitSuggestion({withTerminator} = {}) {
if (this.#committing || this.#canceled || !this.#active) { return false }
const values = this.selectedValues
if (values.length == 0) { return false }
const range = [this.#context.startPosition, this.#context.endPosition]
const terminator = (withTerminator != null ? withTerminator : this.#context.terminator) || " "
this.#committing = true
this.#resultsController.flashSelection(() => {
this.#committing = false
this.#didCommitValuesAtRangeWithTerminator(values, range, terminator)
this.#deactivateSuggestionWithAnimation()
})
if (values.length > 1) {
this.#willCommitValuesAtRangeWithTerminator(values, range, terminator, { editor: this.delegate.editor })
} else {
this.delegate.willCommitValueAtRangeWithTerminator?.(values[0], range, terminator)
}
return true
}
get selectedValues() {
const value = this.#resultsController.getSelectedValue()
if (!value) { return [] }
const valueOnlyHasCommaSeparatedNumbers = /^\d+(,\d+)*$/.test(value)
return valueOnlyHasCommaSeparatedNumbers ? value.split(",") : [value]
}
isActive() {
return this.#active
}
isCanceled() {
return this.#canceled
}
#activateSuggestion() {
if (!this.#canceled) {
this.#active = true
this.#installResultsController()
return this.#updateResults(() => {
if (this.#resultsController.hasResults()) {
return this.#displayResults()
} else {
return this.hideResults()
}
})
}
}
#deactivateSuggestionWithAnimation() {
this.#hideResultsWithAnimation(() => {
this.#deactivateSuggestion()
})
}
#deactivateSuggestion() {
this.#uninstallResultsController()
this.#active = false
this.#canceled = false
}
#cancelSuggestion() {
if (this.#active) {
this.#deactivateSuggestion()
this.#canceled = true
}
}
#resumeSuggestion() {
if (this.#canceled) {
this.#canceled = false
return this.#activateSuggestion()
}
}
#willCommitValuesAtRangeWithTerminator(values, range, terminator, { editor }) {
editor?.setSelectedRange(range) && editor?.deleteInDirection("forward") // Delete user autocomplete input
values.forEach((value) => {
this.delegate.willCommitValueAtRangeWithTerminator?.(value, null, terminator)
range = this.#advanceRangeForNextValue(range)
})
}
#didCommitValuesAtRangeWithTerminator(values, range, terminator) {
values.forEach((value) => {
this.delegate.didCommitValueAtRangeWithTerminator?.(value, range, terminator)
range = this.#advanceRangeForNextValue(range)
})
}
#advanceRangeForNextValue(range) {
const startPosition = range[1] + 1
return Array(startPosition, startPosition + 1)
}
#displayResults() {
if (this.#active) {
const offsets = this.delegate.getOffsetsAtPosition(this.#context.startPosition)
const placement = this.delegate.getResultsPlacement?.()
return this.#resultsController.displayAtOffsets(offsets, {placement})
}
}
#updateResults(callback) {
const query = this.#context?.query
return this.delegate.fetchResultsForQuery(query, results => {
if ((this.#resultsController != null) && (query === this.#context?.query)) {
this.#resultsController.updateResults(results)
return callback?.()
}
})
}
#hideResultsWithAnimation(callback) {
return this.#resultsController?.hideWithAnimation(callback)
}
// Suggestion context delegate
characterMatchesWordBoundary(character) {
if (this.delegate.characterMatchesWordBoundary != null) {
return this.delegate.characterMatchesWordBoundary(character)
} else {
return /[\s\uFFFC]/.test(character)
}
}
matchQueryAndTerminatorForWord(word) {
return this.delegate.matchQueryAndTerminatorForWord(word)
}
// Results controller delegate
didClickOption(option) {
return setTimeout(this.commitSuggestion, 100)
}
didShowResults(element) {
this.hidden = false
return this.delegate.didShowResults?.(element)
}
didHideResults(element) {
this.hidden = true
return this.delegate.didHideResults?.(element)
}
// Keyboard events
didPressKey(event) {
if (this.#committing) { return }
let result
switch (event.keyCode) {
case 9:
result = this.#didPressTabKey()
break
case 10: case 13:
result = this.#didPressReturnKey()
break
case 27:
result = this.#didPressEscapeKey()
break
case 32:
result = this.#didPressSpaceKey()
break
case 38:
result = this.#didPressUpKey()
break
case 40:
result = this.#didPressDownKey()
break
default:
result = this.#didPressKeyWithValue(event.key)
}
if (result === false) {
event.preventDefault()
return event.stopPropagation()
}
}
#didPressTabKey() {
if (this.#active) {
if (this.hidden) {
this.#displayResults()
return false
} else if (!this.#committing) {
if (this.commitSuggestion()) {
return false
}
}
} else if (this.#canceled) {
this.#resumeSuggestion()
return false
}
}
#didPressReturnKey() {
if (this.#active) {
if (this.commitSuggestion()) {
return false
}
}
}
#didPressEscapeKey() {
if (this.#active) {
this.#cancelSuggestion()
return false
}
}
#didPressSpaceKey() {
if (this.#active && this.#spaceMatchesWordBoundary()) {
if (this.commitSuggestion()) {
return false
}
}
}
#didPressUpKey() {
if (this.#active) {
this.#resultsController.selectUp()
return false
}
}
#didPressDownKey() {
if (this.#active) {
this.#resultsController.selectDown()
return false
}
}
#didPressKeyWithValue(value) {
if (this.#active && (value != null) && !this.hidden) {
const result = this.matchQueryAndTerminatorForWord(value)
if (result?.query === "") {
this.#cancelSuggestion()
return false
}
}
}
// Scroll and resize events
didResizeWindow() {
if (this.#active) {
return this.hideResults()
}
}
didScrollWindow(event) {
if (this.#active && (event.target === document)) {
return this.hideResults()
}
}
// Private
#installKeyboardListener() {
window.addEventListener("keydown", this.didPressKey, true)
}
#uninstallKeyboardListener() {
window.removeEventListener("keydown", this.didPressKey, true)
}
#installResizeListeners() {
window.addEventListener("resize", this.didResizeWindow, true)
window.addEventListener("scroll", this.didScrollWindow, true)
}
#uninstallResizeListeners() {
window.removeEventListener("resize", this.didResizeWindow, true)
window.removeEventListener("scroll", this.didScrollWindow, true)
}
#installResultsController() {
if (!this.#resultsController) {
this.#resultsController = new SuggestionResultsController({ id: this.delegate.getSuggestionsIdentifier() })
}
this.#resultsController.delegate = this
}
#uninstallResultsController() {
this.#resultsController?.destroy()
this.#resultsController = null
}
#spaceMatchesWordBoundary() {
return this.characterMatchesWordBoundary(" ")
}
}

View File

@@ -0,0 +1,208 @@
import { getAbsolutePositionForOffsets, getElementMargin, getViewportRect, synchronize, transitionElementWithClass } from "lib/autocomplete/helpers"
export default class SuggestionResultsController {
constructor(options = {}) {
this.revealOption = this.revealOption.bind(this)
this.didMouseDown = this.didMouseDown.bind(this)
this.flashSelection = synchronize(this.flashSelection)
this.id = options.id || `suggestion_results_${generateUUID()}`
this.#createSelectElement()
}
destroy() {
this.hide()
this.#removeSelectElement()
}
displayAtOffsets(offsets, {placement} = {}) {
let availableMaxHeight, elementHeight, height, left, maxHeight, top
this.show()
this.selectElement.style.height = ""
const style = getComputedStyle(this.selectElement)
const margin = getElementMargin(this.selectElement)
const position = getAbsolutePositionForOffsets(offsets)
const elementRect = this.selectElement.getBoundingClientRect()
const viewportRect = getViewportRect()
const availableHeightAbove = position.top - viewportRect.top - margin.top
const availableHeightBelow = viewportRect.bottom - position.bottom - margin.bottom
const thresholdHeight = this.getOptionHeight() * 3
if (availableHeightAbove > thresholdHeight && thresholdHeight > availableHeightBelow) {
if (placement == null) { placement = "above" }
} else {
if (placement == null) { placement = "below" }
}
if (placement === "above") {
availableMaxHeight = availableHeightAbove
} else {
availableMaxHeight = availableHeightBelow
}
if (style.maxHeight === "none") {
maxHeight = availableMaxHeight
} else {
const requestedMaxHeight = parseInt(style.maxHeight, 10)
maxHeight = Math.min(availableMaxHeight, requestedMaxHeight)
}
if (elementRect.height > maxHeight) {
elementHeight = (height = maxHeight)
} else {
elementHeight = elementRect.height
}
if (placement === "above") {
top = position.top - elementHeight - margin.top
} else {
top = position.bottom - margin.top
}
const elementRight = position.left + elementRect.width + margin.right
if (elementRight > viewportRect.right) {
left = position.left - (elementRight - viewportRect.right) - margin.right
} else {
left = position.left - margin.right
}
this.selectElement.style.top = `${top}px`
this.selectElement.style.left = `${left}px`
this.selectElement.style.height = height ? `${height}px` : "auto"
}
show() {
if (!this.visible) {
this.visible = true
this.selectElement.setAttribute("aria-hidden", "false")
this.selectElement.style.visibility = ""
return this.delegate.didShowResults(this.selectElement)
}
}
hide() {
if (this.visible) {
this.visible = false
this.selectElement.style.visibility = "hidden"
this.selectElement.setAttribute("aria-hidden", "true")
return this.delegate.didHideResults(this.selectElement)
}
}
hideWithAnimation = synchronize((callback) => {
if (this.visible) {
return transitionElementWithClass(this.selectElement, "hiding", () => {
this.hide()
return callback()
})
} else {
return callback()
}
})
selectUp() {
this.selectElement.selectedIndex--
return this.revealOption()
}
selectDown() {
this.selectElement.selectedIndex++
return this.revealOption()
}
revealOption() {
const {
selectedOption
} = this.selectElement
if (selectedOption) {
const {scrollTop} = this.selectElement
const selectHeight = this.selectElement.clientHeight
const scrollBottom = scrollTop + selectHeight
const optionTop = selectedOption.offsetTop
const optionHeight = selectedOption.offsetHeight
const optionBottom = optionTop + optionHeight
if (optionTop < scrollTop) {
this.selectElement.scrollTop = optionTop
} else if (optionBottom > scrollBottom) {
this.selectElement.scrollTop = scrollTop + (optionBottom - scrollBottom)
}
}
}
flashSelection(callback) {
const { selectedOption } = this.selectElement
if (selectedOption) {
this.selectElement.classList.add("flashing")
return selectedOption.flash(() => {
this.selectElement.classList.remove("flashing")
return callback()
})
} else {
return callback()
}
}
updateResults(results) {
this.selectElement.innerHTML = results.toString().trim()
if (this.selectElement.selectedIndex != null) {
return requestAnimationFrame(this.revealOption)
} else {
this.selectElement.selectedIndex = 0
}
}
hasResults() {
return this.selectElement.innerHTML.length > 0
}
getSelectedValue() {
return this.selectElement.value
}
getOptionHeight() {
return this.selectElement.optionElements[0]?.offsetHeight != null ? this.selectElement.optionElements[0]?.offsetHeight : 0
}
didMouseDown(event) {
const url = event.target.getAttribute("href")
const option = event.target.closest("suggestion-option")
if (url) {
Turbo.visit(url)
} else if (option) {
option.selectElement.selectedOption = option
this.delegate.didClickOption(option)
this.#cancelEvent(event)
}
}
#createSelectElement() {
this.selectElement = document.createElement("suggestion-select")
this.selectElement.setAttribute("class", "autocomplete__list shadow margin-none unpad")
this.selectElement.addEventListener("mousedown", this.didMouseDown, true)
this.selectElement.addEventListener("click", this.#cancelEvent)
this.selectElement.setAttribute("id", this.id)
this.selectElement.setAttribute("data-behavior", "scrollable_menu")
this.selectElement.setAttribute("aria-live", "assertive")
document.body.appendChild(this.selectElement)
}
#removeSelectElement() {
this.selectElement.removeEventListener("mousedown", this.didMouseDown, true)
this.selectElement.removeEventListener("click", this.#cancelEvent)
return this.selectElement.remove()
}
#cancelEvent(event) {
event.preventDefault()
event.stopPropagation()
}
}

View File

@@ -0,0 +1,33 @@
export function camelize(dashString) {
const element = document.createElement("span")
element.setAttribute(`data-${dashString}`, "")
return Object.keys(element.dataset)[0]
}
export function memoize(object, name, value) {
Object.defineProperty(object, name, { value })
return value
}
export function normalize(string) {
return string.normalize("NFKD").replace(/\p{Diacritic}/gu, "")
}
export function regexpForQuery(query, prefix = "") {
return new RegExp(prefix + patternForQuery(query), "i")
}
export function patternForQuery(query) {
return normalize(query.toString()).split("").map(regexpEscape).join("(.*\\s)?").replace(/\(\.\*\\s\)\? /g, "[^ ]* ")
}
export function uniqueValues(array) {
const set = new Set()
Array.from(array).forEach(value => set.add(value))
return Array.from(set)
}
export function regexpEscape(string) {
return string.toString().replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&")
}

View File

@@ -0,0 +1,19 @@
export function getCookie(name) {
const cookies = document.cookie ? document.cookie.split("; ") : []
const prefix = `${encodeURIComponent(name)}=`
const cookie = cookies.find(cookie => cookie.startsWith(prefix))
if (cookie) {
const value = cookie.split("=").slice(1).join("=")
return value ? decodeURIComponent(value) : undefined
}
}
const twentyYears = 20 * 365 * 24 * 60 * 60 * 1000
export function setCookie(name, value) {
const body = [ name, value ].map(encodeURIComponent).join("=")
const expires = new Date(Date.now() + twentyYears).toUTCString()
const cookie = `${body}; path=/; expires=${expires}`
document.cookie = cookie
}

View File

@@ -0,0 +1,80 @@
import { post } from "@rails/request.js"
import { truncateString } from "helpers/string_helpers"
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
export default class OpengraphEmbedOperation {
constructor(paste) {
this.paste = paste
this.editor = this.paste.editor
this.url = this.paste.string
this.abortController = new AbortController()
}
perform() {
return this.#createOpenGraphMetadataRequest()
.then(response => response.json)
.then(this.#insertOpengraphAttachment.bind(this))
.catch(() => null)
}
abort() {
this.abortController.abort()
}
#createOpenGraphMetadataRequest() {
return post("/unfurl_link", {
body: { url: this.url },
contentType: "application/json",
signal: this.abortController.signal
})
}
#insertOpengraphAttachment(response) {
if (this.#shouldInsertOpengraphPreview) {
const currentRange = this.editor.getSelectedRange()
this.editor.setSelectedRange(this.editor.getSelectedRange())
this.editor.recordUndoEntry("Insert Opengraph preview for Pasted URL")
this.editor.insertAttachment(this.#createOpengraphAttachment(response))
this.editor.setSelectedRange(currentRange)
}
}
get #shouldInsertOpengraphPreview() {
return this.editor.getDocument().toString().includes(this.url)
}
#createOpengraphAttachment(response) {
const { title, url, image, description } = response
const html = this.#generateOpengraphEmbedHTML({ title, url, image, description })
return new Trix.Attachment({
contentType: "application/vnd.actiontext.opengraph-embed",
content: html,
filename: title,
href: url,
url: image,
caption: description
})
}
#generateOpengraphEmbedHTML(embed) {
return `<actiontext-opengraph-embed class="${this.#isTwitterAvatar(embed) ? UNFURLED_TWITTER_AVATAR_CSS_CLASS : ''}">
<div class="og-embed">
<div class="og-embed__content">
<div class="og-embed__title">${truncateString(embed.title, 560)}</div>
<div class="og-embed__description">${truncateString(embed.description, 560)}</div>
</div>
<div class="og-embed__image">
<img src="${embed.image}" class="image" alt="" />
</div>
</div>
</actiontext-opengraph-embed>`
}
#isTwitterAvatar(embed) {
return embed.image.startsWith(TWITTER_AVATAR_URL_PREFIX)
}
}

View File

@@ -0,0 +1,39 @@
export default class Paste {
constructor(range, editor, document) {
this.range = range
this.editor = editor
this.document = document
if (this.document == null) { this.document = this.editor.getDocument() }
this.string = this.document.getStringAtRange(this.range)
}
isURL() {
return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/.test(this.string)
}
getPathname() {
const a = document.createElement("a")
a.href = this.string
return a.pathname
}
isLinked() {
const {href} = this.getCommonAttributes()
return (href != null) && (href !== this.string)
}
getCommonAttributes() {
return this.document.getCommonAttributesAtRange(this.range)
}
getSignificantPaste() {
return new this.constructor(this.getSignificantRange(), this.editor, this.document)
}
getSignificantRange() {
const significantString = this.string.trim()
const startOffset = this.range[0] + this.string.indexOf(significantString)
const endOffset = startOffset + significantString.length
return [startOffset, endOffset]
}
}

View File

@@ -0,0 +1,59 @@
import OpengraphEmbedOperation from "lib/rich_text/unfurl/lib/opengraph_embed_operation"
import Paste from "lib/rich_text/unfurl/lib/paste"
const performOperation = (function() {
let operation = null
let requestId = null
return function(operationToPerform) {
operation?.abort()
cancelAnimationFrame(requestId)
requestId = requestAnimationFrame(function() {
operation = operationToPerform
operation.perform().then(() => operation = null)
})
}
})()
export default class Unfurler {
install() {
this.#addEventListeners()
}
#addEventListeners() {
addEventListener("trix-initialize", function(event) {
if (this.#editorElementPermitsAttribute(event.target, "href")) {
return event.target.addEventListener("trix-paste", this.#didPaste.bind(this))
}
}.bind(this))
}
#didPaste(event) {
const {range} = event.paste
const {editor} = event.target
if (range != null) {
const paste = new Paste(range, editor).getSignificantPaste()
if (paste.isURL()) {
if (this.#editorElementPermitsOpengraphAttachment(event.target)) {
performOperation(new OpengraphEmbedOperation(paste))
}
}
}
}
#editorElementPermitsAttribute(element, attributeName) {
if (element.hasAttribute("data-permitted-attributes")) {
return Array.from(element.getAttribute("data-permitted-attributes").split(" ")).includes(attributeName)
} else {
return true
}
}
#editorElementPermitsOpengraphAttachment(element) {
const permittedAttachmentTypes = element.getAttribute("data-permitted-attachment-types")
return permittedAttachmentTypes && permittedAttachmentTypes.includes("application/vnd.actiontext.opengraph-embed")
}
}

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)
)
}
}