mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-05-05 02:11:01 +09:00
Hello world
First open source release of Campfire 🎉
This commit is contained in:
6
app/javascript/application.js
Normal file
6
app/javascript/application.js
Normal 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"
|
||||
8
app/javascript/controllers/application.js
Normal file
8
app/javascript/controllers/application.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Application } from "@hotwired/stimulus"
|
||||
|
||||
const application = Application.start()
|
||||
|
||||
application.debug = false
|
||||
window.Stimulus = application
|
||||
|
||||
export { application }
|
||||
7
app/javascript/controllers/auto_submit_controller.js
Normal file
7
app/javascript/controllers/auto_submit_controller.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.requestSubmit()
|
||||
}
|
||||
}
|
||||
48
app/javascript/controllers/autocomplete_controller.js
Normal file
48
app/javascript/controllers/autocomplete_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
31
app/javascript/controllers/badge_dot_controller.js
Normal file
31
app/javascript/controllers/badge_dot_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
33
app/javascript/controllers/boost_delete_controller.js
Normal file
33
app/javascript/controllers/boost_delete_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
194
app/javascript/controllers/composer_controller.js
Normal file
194
app/javascript/controllers/composer_controller.js
Normal 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>
|
||||
`
|
||||
}
|
||||
}
|
||||
25
app/javascript/controllers/copy_to_clipboard_controller.js
Normal file
25
app/javascript/controllers/copy_to_clipboard_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
17
app/javascript/controllers/drop_target_controller.js
Normal file
17
app/javascript/controllers/drop_target_controller.js
Normal 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 }})
|
||||
}
|
||||
}
|
||||
7
app/javascript/controllers/element_removal_controller.js
Normal file
7
app/javascript/controllers/element_removal_controller.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
remove() {
|
||||
this.element.remove()
|
||||
}
|
||||
}
|
||||
7
app/javascript/controllers/event_logger_controller.js
Normal file
7
app/javascript/controllers/event_logger_controller.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
log(event) {
|
||||
console.log(event)
|
||||
}
|
||||
}
|
||||
46
app/javascript/controllers/filter_controller.js
Normal file
46
app/javascript/controllers/filter_controller.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
17
app/javascript/controllers/form_controller.js
Normal file
17
app/javascript/controllers/form_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
4
app/javascript/controllers/index.js
Normal file
4
app/javascript/controllers/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { application } from "controllers/application"
|
||||
|
||||
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||
eagerLoadControllersFrom("controllers", application)
|
||||
24
app/javascript/controllers/lightbox_controller.js
Normal file
24
app/javascript/controllers/lightbox_controller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
29
app/javascript/controllers/local_time_controller.js
Normal file
29
app/javascript/controllers/local_time_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
32
app/javascript/controllers/maintain_scroll_controller.js
Normal file
32
app/javascript/controllers/maintain_scroll_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
190
app/javascript/controllers/messages_controller.js
Normal file
190
app/javascript/controllers/messages_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
165
app/javascript/controllers/notifications_controller.js
Normal file
165
app/javascript/controllers/notifications_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
37
app/javascript/controllers/popup_controller.js
Normal file
37
app/javascript/controllers/popup_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
83
app/javascript/controllers/presence_controller.js
Normal file
83
app/javascript/controllers/presence_controller.js
Normal 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"
|
||||
}
|
||||
}
|
||||
35
app/javascript/controllers/pwa_install_controller.js
Normal file
35
app/javascript/controllers/pwa_install_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
22
app/javascript/controllers/read_rooms_controller.js
Normal file
22
app/javascript/controllers/read_rooms_controller.js
Normal 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 } })
|
||||
}
|
||||
}
|
||||
75
app/javascript/controllers/refresh_room_controller.js
Normal file
75
app/javascript/controllers/refresh_room_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
48
app/javascript/controllers/reply_controller.js
Normal file
48
app/javascript/controllers/reply_controller.js
Normal 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>`
|
||||
}
|
||||
}
|
||||
46
app/javascript/controllers/rich_autocomplete_controller.js
Normal file
46
app/javascript/controllers/rich_autocomplete_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
65
app/javascript/controllers/rooms_list_controller.js
Normal file
65
app/javascript/controllers/rooms_list_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
}
|
||||
26
app/javascript/controllers/search_results_controller.js
Normal file
26
app/javascript/controllers/search_results_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
25
app/javascript/controllers/sessions_controller.js
Normal file
25
app/javascript/controllers/sessions_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/javascript/controllers/soft_keyboard_controller.js
Normal file
33
app/javascript/controllers/soft_keyboard_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
36
app/javascript/controllers/sorted_list_controller.js
Normal file
36
app/javascript/controllers/sorted_list_controller.js
Normal 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))
|
||||
}
|
||||
10
app/javascript/controllers/sound_controller.js
Normal file
10
app/javascript/controllers/sound_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
9
app/javascript/controllers/toggle_class_controller.js
Normal file
9
app/javascript/controllers/toggle_class_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
16
app/javascript/controllers/turbo_frame_controller.js
Normal file
16
app/javascript/controllers/turbo_frame_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
11
app/javascript/controllers/turbo_streaming_controller.js
Normal file
11
app/javascript/controllers/turbo_streaming_controller.js
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
14
app/javascript/controllers/upload_preview_controller.js
Normal file
14
app/javascript/controllers/upload_preview_controller.js
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/javascript/controllers/web_share_controller.js
Normal file
36
app/javascript/controllers/web_share_controller.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
65
app/javascript/helpers/dom_helpers.js
Normal file
65
app/javascript/helpers/dom_helpers.js
Normal 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 = ""
|
||||
})
|
||||
}
|
||||
3
app/javascript/helpers/navigator_helpers.js
Normal file
3
app/javascript/helpers/navigator_helpers.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isTouchDevice() {
|
||||
return "ontouchstart" in window && navigator.maxTouchPoints > 0
|
||||
}
|
||||
7
app/javascript/helpers/string_helpers.js
Normal file
7
app/javascript/helpers/string_helpers.js
Normal 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
|
||||
}
|
||||
}
|
||||
39
app/javascript/helpers/timing_helpers.js
Normal file
39
app/javascript/helpers/timing_helpers.js
Normal 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))
|
||||
}
|
||||
3
app/javascript/helpers/turbo_helpers.js
Normal file
3
app/javascript/helpers/turbo_helpers.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function pageIsTurboPreview() {
|
||||
return document.documentElement.hasAttribute("data-turbo-preview")
|
||||
}
|
||||
5
app/javascript/initializers/autocomplete.js
Normal file
5
app/javascript/initializers/autocomplete.js
Normal 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)
|
||||
23
app/javascript/initializers/current.js
Normal file
23
app/javascript/initializers/current.js
Normal 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()
|
||||
29
app/javascript/initializers/highlight.js
Normal file
29
app/javascript/initializers/highlight.js
Normal 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
|
||||
4
app/javascript/initializers/index.js
Normal file
4
app/javascript/initializers/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import "initializers/autocomplete"
|
||||
import "initializers/current"
|
||||
import "initializers/rich_text"
|
||||
import "initializers/highlight"
|
||||
10
app/javascript/initializers/rich_text.js
Normal file
10
app/javascript/initializers/rich_text.js
Normal 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()
|
||||
46
app/javascript/lib/autocomplete/autocomplete_handler.js
Normal file
46
app/javascript/lib/autocomplete/autocomplete_handler.js
Normal 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))
|
||||
}
|
||||
}
|
||||
128
app/javascript/lib/autocomplete/base_autocomplete_handler.js
Normal file
128
app/javascript/lib/autocomplete/base_autocomplete_handler.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
134
app/javascript/lib/autocomplete/collection.js
Normal file
134
app/javascript/lib/autocomplete/collection.js
Normal 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()
|
||||
}
|
||||
}
|
||||
1
app/javascript/lib/autocomplete/constants.js
Normal file
1
app/javascript/lib/autocomplete/constants.js
Normal 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]/
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
103
app/javascript/lib/autocomplete/helpers.js
Normal file
103
app/javascript/lib/autocomplete/helpers.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 : {}
|
||||
}
|
||||
}
|
||||
40
app/javascript/lib/autocomplete/renderer.js
Normal file
40
app/javascript/lib/autocomplete/renderer.js
Normal 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
|
||||
}
|
||||
}
|
||||
82
app/javascript/lib/autocomplete/selection.js
Normal file
82
app/javascript/lib/autocomplete/selection.js
Normal 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))
|
||||
}
|
||||
}
|
||||
59
app/javascript/lib/autocomplete/suggestion_context.js
Normal file
59
app/javascript/lib/autocomplete/suggestion_context.js
Normal 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 ]
|
||||
}
|
||||
}
|
||||
}
|
||||
349
app/javascript/lib/autocomplete/suggestion_controller.js
Normal file
349
app/javascript/lib/autocomplete/suggestion_controller.js
Normal 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(" ")
|
||||
}
|
||||
}
|
||||
208
app/javascript/lib/autocomplete/suggestion_results_controller.js
Normal file
208
app/javascript/lib/autocomplete/suggestion_results_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
33
app/javascript/lib/autocomplete/utils.js
Normal file
33
app/javascript/lib/autocomplete/utils.js
Normal 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, "\\$&")
|
||||
}
|
||||
19
app/javascript/lib/cookie.js
Normal file
19
app/javascript/lib/cookie.js
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
39
app/javascript/lib/rich_text/unfurl/lib/paste.js
Normal file
39
app/javascript/lib/rich_text/unfurl/lib/paste.js
Normal 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]
|
||||
}
|
||||
}
|
||||
59
app/javascript/lib/rich_text/unfurl/unfurler.js
Normal file
59
app/javascript/lib/rich_text/unfurl/unfurler.js
Normal 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")
|
||||
}
|
||||
}
|
||||
91
app/javascript/models/client_message.js
Normal file
91
app/javascript/models/client_message.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const EMOJI_MATCHER = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F)+$/gu
|
||||
|
||||
const SOUND_NAMES = [ "56k", "ballmer", "bell", "bezos", "bueller", "butts", "clowntown", "cottoneyejoe", "crickets", "curb", "dadgummit", "dangerzone", "danielsan", "deeper", "donotwant", "drama", "flawless", "glados", "gogogo", "greatjob", "greyjoy", "guarantee", "heygirl", "honk", "horn", "horror", "inconceivable", "letitgo", "live", "loggins", "makeitso", "noooo", "nyan", "ohmy", "ohyeah", "pushit", "rimshot", "rollout", "rumble", "sax", "secret", "sexyback", "story", "tada", "tmyk", "totes", "trololo", "trombone", "unix", "vuvuzela", "what", "whoomp", "wups", "yay", "yeah", "yodel" ]
|
||||
|
||||
export default class ClientMessage {
|
||||
#template
|
||||
|
||||
constructor(template) {
|
||||
this.#template = template
|
||||
}
|
||||
|
||||
render(clientMessageId, node) {
|
||||
const now = new Date()
|
||||
const body = this.#contentFromNode(node)
|
||||
|
||||
return this.#createFromTemplate({
|
||||
clientMessageId,
|
||||
body,
|
||||
messageTimestamp: Math.floor(now.getTime()),
|
||||
messageDatetime: now.toISOString(),
|
||||
messageClasses: this.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
|
||||
})
|
||||
}
|
||||
|
||||
update(clientMessageId, body) {
|
||||
const element = this.#findWithId(clientMessageId).querySelector(".message__body-content")
|
||||
|
||||
if (element) {
|
||||
element.innerHTML = body
|
||||
}
|
||||
}
|
||||
|
||||
failed(clientMessageId) {
|
||||
const element = this.#findWithId(clientMessageId)
|
||||
|
||||
if (element) {
|
||||
element.classList.add("message--failed")
|
||||
}
|
||||
}
|
||||
|
||||
#findWithId(clientMessageId) {
|
||||
return document.querySelector(`#message_${clientMessageId}`)
|
||||
}
|
||||
|
||||
#contentFromNode(node) {
|
||||
if (this.#isPlayCommand(node)) {
|
||||
return `<span class="pending">Playing ${this.#matchPlayCommand(node)}…</span>`
|
||||
} else if (this.#isRichText(node)) {
|
||||
return this.#richTextContent(node)
|
||||
} else {
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#isPlayCommand(node) {
|
||||
return this.#matchPlayCommand(node)
|
||||
}
|
||||
|
||||
#matchPlayCommand(node) {
|
||||
return this.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
||||
}
|
||||
|
||||
#stripWrapperElement(node) {
|
||||
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
|
||||
}
|
||||
|
||||
|
||||
#isRichText(node) {
|
||||
return typeof(node) != "string"
|
||||
}
|
||||
|
||||
#richTextContent(node) {
|
||||
return `<div class="trix-content">${node.innerHTML}</div>`
|
||||
}
|
||||
|
||||
|
||||
#createFromTemplate(data) {
|
||||
let html = this.#template.innerHTML
|
||||
|
||||
for (const key in data) {
|
||||
html = html.replaceAll(`$${key}$`, data[key])
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
#containsOnlyEmoji(text) {
|
||||
return text?.match(EMOJI_MATCHER)
|
||||
}
|
||||
}
|
||||
41
app/javascript/models/file_uploader.js
Normal file
41
app/javascript/models/file_uploader.js
Normal file
@@ -0,0 +1,41 @@
|
||||
export default class FileUploader {
|
||||
constructor(file, url, clientMessageId, progressCallback) {
|
||||
this.file = file
|
||||
this.url = url
|
||||
this.clientMessageId = clientMessageId
|
||||
this.progressCallback = progressCallback
|
||||
}
|
||||
|
||||
upload() {
|
||||
const formdata = new FormData()
|
||||
formdata.append("message[attachment]", this.file)
|
||||
formdata.append("message[client_message_id]", this.clientMessageId)
|
||||
|
||||
const req = new XMLHttpRequest()
|
||||
req.open("POST", this.url)
|
||||
req.setRequestHeader("X-CSRF-Token", document.querySelector("meta[name=csrf-token]").content)
|
||||
req.upload.addEventListener("progress", this.#uploadProgress.bind(this))
|
||||
|
||||
const result = new Promise((resolve, reject) => {
|
||||
req.addEventListener("readystatechange", () => {
|
||||
if (req.readyState === XMLHttpRequest.DONE) {
|
||||
if (req.status < 400) {
|
||||
resolve(req.response)
|
||||
} else {
|
||||
reject()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.send(formdata)
|
||||
return result
|
||||
}
|
||||
|
||||
#uploadProgress(event) {
|
||||
if (event.lengthComputable) {
|
||||
const percent = Math.round((event.loaded / event.total) * 100)
|
||||
this.progressCallback(percent, this.clientMessageId, this.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
app/javascript/models/message_formatter.js
Normal file
95
app/javascript/models/message_formatter.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { onNextEventLoopTick } from "helpers/timing_helpers"
|
||||
|
||||
const THREADING_TIME_WINDOW_MILLISECONDS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export const ThreadStyle = {
|
||||
none: 0,
|
||||
thread: 1,
|
||||
}
|
||||
|
||||
export default class MessageFormatter {
|
||||
#userId
|
||||
#classes
|
||||
#dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "short" })
|
||||
|
||||
constructor(userId, classes) {
|
||||
this.#userId = userId
|
||||
this.#classes = classes
|
||||
}
|
||||
|
||||
format(message, threadstyle) {
|
||||
this.#setMeClass(message)
|
||||
this.#highlightMentions(message)
|
||||
|
||||
if (threadstyle != ThreadStyle.none) {
|
||||
this.#threadMessage(message)
|
||||
this.#setFirstOfDayClass(message)
|
||||
}
|
||||
|
||||
this.#makeVisible(message)
|
||||
}
|
||||
|
||||
formatBody(body) {
|
||||
this.#highlightCode(body)
|
||||
}
|
||||
|
||||
#setMeClass(message) {
|
||||
const isMe = message.dataset.userId == this.#userId
|
||||
message.classList.toggle(this.#classes.me, isMe)
|
||||
}
|
||||
|
||||
#makeVisible(message) {
|
||||
message.classList.add(this.#classes.formatted)
|
||||
}
|
||||
|
||||
#setFirstOfDayClass(message) {
|
||||
let showSeparator = true
|
||||
|
||||
if (message.dataset.messageTimestamp && message.previousElementSibling?.dataset?.messageTimestamp) {
|
||||
const prev = new Date(Number(message.previousElementSibling.dataset.messageTimestamp))
|
||||
const curr = new Date(Number(message.dataset.messageTimestamp))
|
||||
|
||||
showSeparator = this.#dateFormatter.format(prev) !== this.#dateFormatter.format(curr)
|
||||
}
|
||||
|
||||
message.classList.toggle(this.#classes.firstOfDay, showSeparator)
|
||||
}
|
||||
|
||||
#threadMessage(message) {
|
||||
if (message.previousElementSibling) {
|
||||
const isSameUser = message.previousElementSibling.dataset.userId == message.dataset.userId
|
||||
const previousMessageIsRecent = this.#previousMessageIsRecent(message)
|
||||
|
||||
message.classList.toggle(this.#classes.threaded, isSameUser && previousMessageIsRecent)
|
||||
}
|
||||
}
|
||||
|
||||
#highlightMentions(message) {
|
||||
const mentionsCurrentUser = message.querySelector(this.#selectorForCurrentUser) !== null
|
||||
message.classList.toggle(this.#classes.mentioned, mentionsCurrentUser)
|
||||
}
|
||||
|
||||
#highlightCode(body) {
|
||||
body.querySelectorAll("pre").forEach(block => {
|
||||
onNextEventLoopTick(() => this.#highlightCodeBlock(block))
|
||||
})
|
||||
}
|
||||
|
||||
#highlightCodeBlock(block) {
|
||||
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
|
||||
}
|
||||
|
||||
#isPlainText(element) {
|
||||
return Array.from(element.childNodes).every(node => node.nodeType === Node.TEXT_NODE)
|
||||
}
|
||||
|
||||
#previousMessageIsRecent(message) {
|
||||
const previousTimestamp = message.previousElementSibling.dataset.messageTimestamp
|
||||
const threadTimestamp = message.dataset.messageTimestamp
|
||||
return Math.abs(previousTimestamp - threadTimestamp) <= THREADING_TIME_WINDOW_MILLISECONDS
|
||||
}
|
||||
|
||||
get #selectorForCurrentUser() {
|
||||
return `.mention img[src^="/users/${Current.user.id}/avatar"]`
|
||||
}
|
||||
}
|
||||
196
app/javascript/models/message_paginator.js
Normal file
196
app/javascript/models/message_paginator.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import { get } from "@rails/request.js"
|
||||
import {
|
||||
insertHTMLFragment,
|
||||
parseHTMLFragment,
|
||||
keepScroll,
|
||||
trimChildren,
|
||||
} from "helpers/dom_helpers"
|
||||
import { ThreadStyle } from "models/message_formatter"
|
||||
|
||||
const MAX_MESSAGES = 300
|
||||
const MAX_MESSAGES_LEEWAY = 20
|
||||
|
||||
|
||||
class ScrollTracker {
|
||||
#container
|
||||
#callback
|
||||
#intersectionObserver
|
||||
#mutationObserver
|
||||
#firstChildWasHidden
|
||||
|
||||
constructor(container, callback) {
|
||||
this.#container = container
|
||||
this.#callback = callback
|
||||
this.#intersectionObserver = new IntersectionObserver(this.#handleIntersection.bind(this), { root: container })
|
||||
this.#mutationObserver = new MutationObserver(this.#childrenChanged.bind(this))
|
||||
|
||||
this.#mutationObserver.observe(container, { childList: true })
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.#childrenChanged()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#intersectionObserver.disconnect()
|
||||
}
|
||||
|
||||
#childrenChanged() {
|
||||
this.disconnect()
|
||||
|
||||
if (this.#container.firstElementChild) {
|
||||
this.#firstChildWasHidden = false
|
||||
|
||||
this.#intersectionObserver.observe(this.#container.firstElementChild)
|
||||
this.#intersectionObserver.observe(this.#container.lastElementChild)
|
||||
}
|
||||
}
|
||||
|
||||
#handleIntersection(entries) {
|
||||
for (const entry of entries) {
|
||||
// Don't callback when the first child is shown, unless it had previously
|
||||
// been hidden. This avoids the issue that adding new pages will always
|
||||
// fire the callback for the first item before the scroll position is
|
||||
// adjusted.
|
||||
//
|
||||
// We don't do this with the last item, because it's possible that
|
||||
// fetching a page could return less than a screenfull.
|
||||
const isFirst = entry.target === this.#container.firstElementChild
|
||||
const significantReveal = (isFirst && this.#firstChildWasHidden) || !isFirst
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
if (significantReveal) {
|
||||
this.#callback(entry.target)
|
||||
}
|
||||
} else {
|
||||
if (isFirst) {
|
||||
this.#firstChildWasHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class MessagePaginator {
|
||||
#container
|
||||
#url
|
||||
#messageFormatter
|
||||
#allContentViewedCallback
|
||||
#scrollTracker
|
||||
#upToDate = true
|
||||
|
||||
constructor(container, url, messageFormatter, allContentViewedCallback) {
|
||||
this.#container = container
|
||||
this.#url = url
|
||||
this.#messageFormatter = messageFormatter
|
||||
this.#allContentViewedCallback = allContentViewedCallback
|
||||
this.#scrollTracker = new ScrollTracker(container, this.#messageBecameVisible.bind(this))
|
||||
}
|
||||
|
||||
|
||||
// API
|
||||
|
||||
monitor() {
|
||||
this.#scrollTracker.connect()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#scrollTracker.disconnect()
|
||||
}
|
||||
|
||||
get upToDate() {
|
||||
return this.#upToDate
|
||||
}
|
||||
|
||||
set upToDate(value) {
|
||||
this.#upToDate = value
|
||||
}
|
||||
|
||||
async resetToLastPage() {
|
||||
this.upToDate = true
|
||||
await this.#showLastPage()
|
||||
}
|
||||
|
||||
async trimExcessMessages(top) {
|
||||
const overage = this.#container.children.length - MAX_MESSAGES
|
||||
if (overage > MAX_MESSAGES_LEEWAY) {
|
||||
trimChildren(overage, this.#container, top)
|
||||
if (!top) {
|
||||
this.upToDate = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal
|
||||
|
||||
#messageBecameVisible(element) {
|
||||
const messageId = element.dataset.messageId
|
||||
const firstMesage = element === this.#container.firstElementChild
|
||||
const lastMessage = element === this.#container.lastElementChild
|
||||
|
||||
if (messageId) {
|
||||
if (firstMesage) {
|
||||
this.#addPage({ before: messageId }, true)
|
||||
}
|
||||
if (lastMessage && !this.upToDate) {
|
||||
this.#addPage({ after: messageId }, false)
|
||||
}
|
||||
if (lastMessage && this.upToDate) {
|
||||
this.#allContentViewedCallback?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #showLastPage() {
|
||||
const resp = await this.#fetchPage()
|
||||
if (resp.statusCode === 200) {
|
||||
const page = await this.#formatPage(resp)
|
||||
this.#container.replaceChildren(page)
|
||||
}
|
||||
}
|
||||
|
||||
async #addPage(params, top) {
|
||||
const resp = await this.#fetchPage(params)
|
||||
|
||||
if (resp.statusCode === 204 && !top) {
|
||||
this.upToDate = true
|
||||
this.#allContentViewedCallback?.()
|
||||
}
|
||||
|
||||
if (resp.statusCode === 200) {
|
||||
const page = await this.#formatPage(resp)
|
||||
const lastNewElement = page.lastElementChild
|
||||
|
||||
keepScroll(this.#container, top, () => {
|
||||
insertHTMLFragment(page, this.#container, top)
|
||||
|
||||
// Ensure formatting is correct over page boundaries
|
||||
if (top && lastNewElement?.nextElementSibling) {
|
||||
this.#messageFormatter.format(lastNewElement.nextElementSibling, ThreadStyle.thread)
|
||||
}
|
||||
})
|
||||
|
||||
this.trimExcessMessages(!top)
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchPage(params) {
|
||||
const url = new URL(this.#url)
|
||||
for (const param in params) {
|
||||
url.searchParams.set(param, params[param])
|
||||
}
|
||||
|
||||
return await get(url)
|
||||
}
|
||||
|
||||
async #formatPage(response) {
|
||||
const text = await response.html
|
||||
const fragment = parseHTMLFragment(text)
|
||||
|
||||
for (const message of fragment.querySelectorAll(".message")) {
|
||||
this.#messageFormatter.format(message, ThreadStyle.thread)
|
||||
}
|
||||
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
57
app/javascript/models/scroll_manager.js
Normal file
57
app/javascript/models/scroll_manager.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const AUTO_SCROLL_THRESHOLD = 100
|
||||
|
||||
export default class ScrollManager {
|
||||
static #pendingOperations = Promise.resolve()
|
||||
|
||||
#container
|
||||
|
||||
constructor(container) {
|
||||
this.#container = container
|
||||
}
|
||||
|
||||
async autoscroll(forceScroll, render = () => {}) {
|
||||
return this.#appendOperation(async () => {
|
||||
const wasNearEnd = this.#scrolledNearEnd
|
||||
|
||||
await render()
|
||||
|
||||
if (wasNearEnd || forceScroll) {
|
||||
this.#container.scrollTop = this.#container.scrollHeight
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async keepScroll(top, render) {
|
||||
return this.#appendOperation(async () => {
|
||||
const scrollTop = this.#container.scrollTop
|
||||
const scrollHeight = this.#container.scrollHeight
|
||||
|
||||
await render()
|
||||
|
||||
if (top) {
|
||||
this.#container.scrollTop = scrollTop + (this.#container.scrollHeight - scrollHeight)
|
||||
} else {
|
||||
this.#container.scrollTop = scrollTop
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
#appendOperation(operation) {
|
||||
ScrollManager.#pendingOperations =
|
||||
ScrollManager.#pendingOperations.then(operation)
|
||||
return ScrollManager.#pendingOperations
|
||||
}
|
||||
|
||||
get #scrolledNearEnd() {
|
||||
return this.#distanceScrolledFromEnd <= AUTO_SCROLL_THRESHOLD
|
||||
}
|
||||
|
||||
get #distanceScrolledFromEnd() {
|
||||
return this.#container.scrollHeight - this.#container.scrollTop - this.#container.clientHeight
|
||||
}
|
||||
}
|
||||
42
app/javascript/models/typing_tracker.js
Normal file
42
app/javascript/models/typing_tracker.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const REFRESH_INTERVAL = 1000
|
||||
const TYPING_TIMEOUT = 5000
|
||||
|
||||
export default class TypingTracker {
|
||||
constructor(callback) {
|
||||
this.callback = callback
|
||||
this.currentlyTyping = {}
|
||||
this.timer = setInterval(this.#refresh.bind(this), REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
close() {
|
||||
clearInterval(this.timer)
|
||||
}
|
||||
|
||||
add(name) {
|
||||
this.currentlyTyping[name] = Date.now()
|
||||
this.#refresh()
|
||||
}
|
||||
|
||||
remove(name) {
|
||||
delete this.currentlyTyping[name]
|
||||
this.#refresh()
|
||||
}
|
||||
|
||||
#refresh() {
|
||||
this.#purgeInactive()
|
||||
const names = Object.keys(this.currentlyTyping).sort()
|
||||
|
||||
if (names.length > 0) {
|
||||
this.callback(`${names.join(", ")}`)
|
||||
} else {
|
||||
this.callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
#purgeInactive() {
|
||||
const cutoff = Date.now() - TYPING_TIMEOUT
|
||||
this.currentlyTyping = Object.fromEntries(
|
||||
Object.entries(this.currentlyTyping).filter(([_name, timestamp]) => timestamp > cutoff)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user