Make sending messages feel snappy

This commit is contained in:
Stanko K.R.
2025-08-04 07:24:58 +02:00
parent 13eeb8574d
commit e49c7cb1cb
37 changed files with 700 additions and 157 deletions
@@ -0,0 +1,63 @@
import { Controller } from "@hotwired/stimulus"
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "textInput", "submitButton", "clientMessageIdInput" ]
static outlets = [ "conversation--messages" ]
#submittingMessage
connect() {
this.#submittingMessage = false
onNextEventLoopTick(() => this.textInputTarget.focus())
}
submit(event) {
event.preventDefault()
if (!this.#submittingMessage) {
this.#submittingMessage = true
this.#submitMessage()
}
}
submitEnd(even) {
this.#submittingMessage = false
if (event.detail.success) {
this.#reset()
} else {
this.conversationMessagesOutlet.failPendingMessage(this.clientMessageIdInputTarget.value)
}
}
async #submitMessage() {
if (this.#validInput()) {
const messageId = this.#generateMessageId()
await this.conversationMessagesOutlet.insertPendingMessage(messageId, this.#messageText)
await nextFrame()
this.conversationMessagesOutlet.scrollToBottom()
this.clientMessageIdInputTarget.value = messageId
this.element.requestSubmit()
}
}
get #messageText() {
return this.textInputTarget.value
}
#validInput() {
return this.#messageText.trim().length > 0
}
#generateMessageId() {
return Math.random().toString(36).slice(2)
}
#reset() {
this.textInputTarget.value = ""
this.clientMessageIdInputTarget.value = ""
}
}
@@ -0,0 +1,108 @@
import { Controller } from "@hotwired/stimulus"
import { nextFrame } from "helpers/timing_helpers"
export default class extends Controller {
static SCROLL_TRESHOLD = 100
static EMOJI_MATCHER = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F)+$/gu
static targets = [ "messages", "messageTemplate" ]
static classes = [ "failedMessage", "emojiMessage" ]
connect() {
this.scrollToBottom()
}
failPendingMessage(clientMessageId) {
const message = this.element.querySelector(`#message_${clientMessageId}`)
if (message) {
message.classList.add(this.failedMessageClass)
}
}
async insertPendingMessage(clientMessageId, content) {
const html = this.#pendingMessageHtml({
clientMessageId: clientMessageId,
content: content,
extraClasses: this.#allEmoji(content) ? this.emojiMessageClass : ""
})
this.messagesTarget.insertAdjacentHTML("beforeend", html)
await nextFrame()
this.scrollToBottom()
}
scrollToBottom() {
this.element.scrollTop = this.element.scrollHeight
}
beforeStreamRender(event) {
const target = event.detail.newStream.getAttribute("target")
if (target === this.messagesTarget.id) {
this.#handleScrollPositionOnTurboStreamRender(event)
}
}
#pendingMessageHtml(data) {
let html = this.messageTemplateTarget.innerHTML
for (const key in data) {
html = html.replaceAll(`$${key}$`, data[key])
}
return html
}
#allEmoji(content) {
return content.match(this.constructor.EMOJI_MATCHER)
}
#handleScrollPositionOnTurboStreamRender(event) {
if (event.detail.newStream.action === "append") {
this.#scrollToBottomOnTurboStreamRender(event)
} else {
this.#preserveScrollPositionOnTurboStreamRender(event)
}
}
#scrollToBottomOnTurboStreamRender(event) {
const render = event.detail.render
event.detail.render = async (streamElement) => {
await render(streamElement)
await nextFrame()
this.scrollToBottom()
}
}
#preserveScrollPositionOnTurboStreamRender(event) {
const render = event.detail.render
event.detail.render = async (streamElement) => {
const previousScrollHeight = this.element.scrollHeight
const previousScrollTop = this.element.scrollTop
const previouslyWasAtBottom = this.#scrolledToBottom
await render(streamElement)
if (previouslyWasAtBottom) {
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight
} else {
const newScrollHeight = this.element.scrollHeight
const scrollDelta = newScrollHeight - previousScrollHeight
this.element.scrollTop = previousScrollTop + scrollDelta
}
await nextFrame()
}
}
get #scrolledToBottom() {
const scrollBottom = this.element.scrollTop + this.element.clientHeight
const contentBottom = this.element.scrollHeight - this.constructor.SCROLL_THRESHOLD
return scrollBottom >= contentBottom
}
}