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 } }