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
@@ -0,0 +1,8 @@
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }
@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.requestSubmit()
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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>
`
}
}
@@ -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
}
}
@@ -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 }})
}
}
@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
remove() {
this.element.remove()
}
}
@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
log(event) {
console.log(event)
}
}
@@ -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)
})
}
}
@@ -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()
}
}
+4
View File
@@ -0,0 +1,4 @@
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
@@ -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;
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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()
}
}
@@ -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"
}
}
@@ -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
}
}
@@ -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 } })
}
}
@@ -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
}
}
@@ -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>`
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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" })
}
}
@@ -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)
}
}
@@ -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()
}
}
}
}
}
@@ -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()
}
}
@@ -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))
}
@@ -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()
}
}
@@ -0,0 +1,9 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static classes = [ "toggle" ]
toggle() {
this.element.classList.toggle(this.toggleClass)
}
}
@@ -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)
}
}
@@ -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")
}
}
@@ -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))
}
@@ -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) }
}
}
}
@@ -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 })
}
}