mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-09-27 02:26:23 +09:00
5c5821a395
The composer stores what's being written in localStorage per room and restores it when the room is opened again. Sending clears it.
218 lines
5.9 KiB
JavaScript
218 lines
5.9 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
import FileUploader from "models/file_uploader"
|
|
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
|
|
import { escapeHTML } from "helpers/string_helpers"
|
|
|
|
export default class extends Controller {
|
|
static classes = ["toolbar"]
|
|
static targets = [ "clientid", "fields", "fileList", "text" ]
|
|
static values = { roomId: Number }
|
|
static outlets = [ "messages" ]
|
|
|
|
#files = []
|
|
|
|
connect() {
|
|
this.#restoreDraft()
|
|
|
|
if (!this.#usingTouchDevice) {
|
|
onNextEventLoopTick(() => this.textTarget.focus())
|
|
}
|
|
}
|
|
|
|
saveDraft() {
|
|
if (this.textTarget.isBlank) {
|
|
localStorage.removeItem(this.#draftKey)
|
|
} else {
|
|
localStorage.setItem(this.#draftKey, this.textTarget.value)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
this.textTarget.value = content
|
|
this.textTarget.focus()
|
|
this.textTarget.selection.placeCursorAtTheEnd()
|
|
}
|
|
|
|
submitByKeyboard(event) {
|
|
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
|
|
|
|
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
|
|
const metaEnter = event.metaKey || event.ctrlKey
|
|
const plainEnter = !event.shiftKey && !event.isComposing
|
|
|
|
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
|
event.stopPropagation()
|
|
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
|
|
}
|
|
|
|
#restoreDraft() {
|
|
const draft = localStorage.getItem(this.#draftKey)
|
|
|
|
if (draft) {
|
|
this.textTarget.value = draft
|
|
this.textTarget.selection.placeCursorAtTheEnd()
|
|
}
|
|
}
|
|
|
|
get #draftKey() {
|
|
return `composer-draft-${this.roomIdValue}`
|
|
}
|
|
|
|
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.isBlank
|
|
}
|
|
|
|
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 = ""
|
|
localStorage.removeItem(this.#draftKey)
|
|
}
|
|
|
|
#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>
|
|
`
|
|
}
|
|
}
|