mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-09-01 11:21:31 +09:00
Hello world
First open source release of Campfire 🎉
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user