|
@@ -1,72 +1,173 @@
|
|
|
-import './message-history';
|
|
|
+import { html } from "lit";
|
|
|
+import { api } from "@converse/headless";
|
|
|
import tplSpinner from "templates/spinner.js";
|
|
|
-import { CustomElement } from '../components/element.js';
|
|
|
-import { api } from '@converse/headless';
|
|
|
-import { html } from 'lit';
|
|
|
-import { markScrolled } from './utils.js';
|
|
|
+import { CustomElement } from "../components/element.js";
|
|
|
+import { onScrolledDown } from "./utils.js";
|
|
|
+import "./message-history";
|
|
|
|
|
|
-import './styles/chat-content.scss';
|
|
|
+import "./styles/chat-content.scss";
|
|
|
|
|
|
+const WINDOW_DELTA = 5; // How much the window should move at a time.
|
|
|
+const WINDOW_SIZE = 100;
|
|
|
|
|
|
+/**
|
|
|
+ * Implements a virtualized list of chat messages, which means only a subset of
|
|
|
+ * messages, the `WINDOW_SIZE`, gets rendered to the DOM, and this subset
|
|
|
+ * gets updated as the user scrolls up and down.
|
|
|
+ */
|
|
|
export default class ChatContent extends CustomElement {
|
|
|
-
|
|
|
- constructor () {
|
|
|
+ /**
|
|
|
+ * @typedef {import('../../plugins/chatview/chat.js').default} ChatView
|
|
|
+ * @typedef {import('../../plugins/muc-views/muc.js').default} MUCView
|
|
|
+ * @typedef {import('../../plugins/muc-views/occupant').default} MUCOccupantView
|
|
|
+ */
|
|
|
+ constructor() {
|
|
|
super();
|
|
|
this.model = null;
|
|
|
+ this.scrollTop = 0;
|
|
|
+ this.scroll_debounce = null;
|
|
|
+
|
|
|
+ // Index of the top message in the virtualized list window.
|
|
|
+ // If all messages are shown, this value is equal to zero.
|
|
|
+ this.window_top = 0;
|
|
|
+
|
|
|
+ // Index of the bottom message in the virtualized list window.
|
|
|
+ // If all messages are shown, this value is equal to the total minus one.
|
|
|
+ this.window_bottom = 0;
|
|
|
+
|
|
|
+ this.scrollHandler = /** @param {Event} ev */ (ev) => {
|
|
|
+ if (this.mark_scrolled_debounce) {
|
|
|
+ clearTimeout(this.scroll_debounce);
|
|
|
+ }
|
|
|
+ this.mark_scrolled_debounce = setTimeout(() => {
|
|
|
+ this.#markScrolled(ev);
|
|
|
+ }, 250);
|
|
|
+
|
|
|
+ requestAnimationFrame(() => this.#setWindow());
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
- static get properties () {
|
|
|
+ static get properties() {
|
|
|
return {
|
|
|
- model: { type: Object }
|
|
|
- }
|
|
|
+ model: { type: Object },
|
|
|
+ window_top: { state: true },
|
|
|
+ window_bottom: { state: true },
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
- disconnectedCallback () {
|
|
|
- super.disconnectedCallback();
|
|
|
- this.removeEventListener('scroll', markScrolled);
|
|
|
+ async initialize() {
|
|
|
+ await this.model.initialized;
|
|
|
+ this.listenTo(this.model.messages, "add", () => this.#onNumMessagesChanged());
|
|
|
+ this.listenTo(this.model.messages, "remove", () => this.#onNumMessagesChanged());
|
|
|
+ this.listenTo(this.model.messages, "reset", () => this.#onNumMessagesChanged());
|
|
|
+ this.listenTo(this.model.messages, "change", () => this.requestUpdate());
|
|
|
+ this.listenTo(this.model.messages, "rendered", () => this.requestUpdate());
|
|
|
+ this.listenTo(this.model, "historyPruned", () => this.#setWindow());
|
|
|
+ this.listenTo(this.model.notifications, "change", () => this.requestUpdate());
|
|
|
+ this.listenTo(this.model.ui, "change", () => this.requestUpdate());
|
|
|
+ this.listenTo(this.model.ui, "change:scrolled", () => this.scrollDown());
|
|
|
+
|
|
|
+ this.window_bottom = this.model.messages.length - 1;
|
|
|
+ this.window_top = Math.max(0, this.window_bottom - WINDOW_SIZE);
|
|
|
+
|
|
|
+ this.requestUpdate();
|
|
|
}
|
|
|
|
|
|
- connectedCallback () {
|
|
|
- super.connectedCallback();
|
|
|
- this.addEventListener('scroll', markScrolled);
|
|
|
+ render() {
|
|
|
+ if (!this.model) return "";
|
|
|
+
|
|
|
+ return html`
|
|
|
+ <div class="chat-content__messages" @scroll="${/** @param {Event} ev */ (ev) => this.scrollHandler(ev)}">
|
|
|
+ <div class="chat-content__notifications">${this.model.getNotificationsText()}</div>
|
|
|
+ <converse-message-history
|
|
|
+ .model="${this.model}"
|
|
|
+ .messages="${this.model.messages.slice(this.window_top, this.window_bottom + 1)}"
|
|
|
+ ></converse-message-history>
|
|
|
+ </div>
|
|
|
+ ${this.model.ui?.get("chat-content-spinner-top") ? tplSpinner() : ""}
|
|
|
+ `;
|
|
|
}
|
|
|
|
|
|
- async initialize () {
|
|
|
- await this.model.initialized;
|
|
|
- this.listenTo(this.model.messages, 'add', () => this.requestUpdate());
|
|
|
- this.listenTo(this.model.messages, 'change', () => this.requestUpdate());
|
|
|
- this.listenTo(this.model.messages, 'remove', () => this.requestUpdate());
|
|
|
- this.listenTo(this.model.messages, 'rendered', () => this.requestUpdate());
|
|
|
- this.listenTo(this.model.messages, 'reset', () => this.requestUpdate());
|
|
|
- this.listenTo(this.model.notifications, 'change', () => this.requestUpdate());
|
|
|
- this.listenTo(this.model.ui, 'change', () => this.requestUpdate());
|
|
|
- this.listenTo(this.model.ui, 'change:scrolled', this.scrollDown);
|
|
|
+ #onNumMessagesChanged() {
|
|
|
+ this.#setWindow();
|
|
|
this.requestUpdate();
|
|
|
}
|
|
|
|
|
|
- render () {
|
|
|
- if (!this.model) {
|
|
|
- return '';
|
|
|
+ /**
|
|
|
+ * Called when the chat content is scrolled up or down.
|
|
|
+ * We want to record when the user has scrolled away from
|
|
|
+ * the bottom, so that we don't automatically scroll away
|
|
|
+ * from what the user is reading when new messages are received.
|
|
|
+ * @param {Event} ev
|
|
|
+ */
|
|
|
+ #markScrolled(ev) {
|
|
|
+ let scrolled = true;
|
|
|
+
|
|
|
+ const el = /** @type {ChatView|MUCView|MUCOccupantView} */ (ev.target);
|
|
|
+ const is_at_bottom = Math.floor(el.scrollTop) === 0;
|
|
|
+ const is_at_top =
|
|
|
+ Math.ceil(el.clientHeight - el.scrollTop) >= el.scrollHeight - Math.ceil(el.scrollHeight / 20);
|
|
|
+
|
|
|
+ if (is_at_bottom) {
|
|
|
+ scrolled = false;
|
|
|
+ onScrolledDown(this.model);
|
|
|
+ } else if (is_at_top) {
|
|
|
+ /**
|
|
|
+ * Triggered once the chat's message area has been scrolled to the top
|
|
|
+ * @event _converse#chatBoxScrolledUp
|
|
|
+ * @property { _converse.ChatBoxView | MUCView } view
|
|
|
+ * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
|
|
|
+ */
|
|
|
+ api.trigger("chatBoxScrolledUp", el);
|
|
|
+ }
|
|
|
+ if (this.model.get("scolled") !== scrolled) {
|
|
|
+ this.model.ui.set({ scrolled });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Scroll event handler, which sets new window bounds based on whether the
|
|
|
+ * scrollbar is at the top or bottom, or otherwise based on which
|
|
|
+ * messages are visible within the scrollable area.
|
|
|
+ */
|
|
|
+ #setWindow() {
|
|
|
+ const total_messages = this.model.messages.length;
|
|
|
+ const container = /** @type {HTMLElement} */ (this.querySelector(".chat-content__messages"));
|
|
|
+
|
|
|
+ // The amount before the actual top/bottom where we are close enough to
|
|
|
+ // want to update the window. Set to 25% of the scrollable container.
|
|
|
+ const delta = Math.ceil(container.scrollHeight / 5);
|
|
|
+
|
|
|
+ const is_at_top = Math.ceil(container.clientHeight - container.scrollTop) >= container.scrollHeight - delta;
|
|
|
+
|
|
|
+ if (is_at_top) {
|
|
|
+ this.window_top = Math.max(0, this.window_top - WINDOW_DELTA);
|
|
|
+ this.window_bottom = this.window_top + WINDOW_SIZE;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const is_at_bottom = Math.floor(container.scrollTop) === 0;
|
|
|
+ if (is_at_bottom) {
|
|
|
+ this.window_bottom = total_messages - 1;
|
|
|
+ this.window_top = Math.max(0, this.window_bottom - WINDOW_SIZE);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const is_close_to_bottom = Math.floor(Math.abs(container.scrollTop)) < delta;
|
|
|
+ if (is_close_to_bottom) {
|
|
|
+ this.window_bottom = Math.min(total_messages - 1, this.window_bottom + WINDOW_DELTA);
|
|
|
+ this.window_top = Math.max(0, this.window_bottom - WINDOW_SIZE);
|
|
|
+ return;
|
|
|
}
|
|
|
- // This element has "flex-direction: reverse", so elements here are
|
|
|
- // shown in reverse order.
|
|
|
- return html`
|
|
|
- <div class="chat-content__notifications">${this.model.getNotificationsText()}</div>
|
|
|
- <converse-message-history
|
|
|
- .model=${this.model}
|
|
|
- .messages=${[...this.model.messages.models]}>
|
|
|
- </converse-message-history>
|
|
|
- ${ this.model.ui?.get('chat-content-spinner-top') ? tplSpinner() : '' }
|
|
|
- `;
|
|
|
}
|
|
|
|
|
|
- scrollDown () {
|
|
|
- if (this.model.ui.get('scrolled')) {
|
|
|
+ scrollDown() {
|
|
|
+ if (this.model.ui.get("scrolled")) {
|
|
|
return;
|
|
|
}
|
|
|
if (this.scrollTo) {
|
|
|
- const behavior = this.scrollTop ? 'smooth' : 'auto';
|
|
|
- this.scrollTo({ 'top': 0, behavior });
|
|
|
+ const behavior = this.scrollTop ? "smooth" : "auto";
|
|
|
+ this.scrollTo({ top: 0, behavior });
|
|
|
} else {
|
|
|
this.scrollTop = 0;
|
|
|
}
|
|
@@ -77,8 +178,8 @@ export default class ChatContent extends CustomElement {
|
|
|
* @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
|
|
|
* @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... });
|
|
|
*/
|
|
|
- api.trigger('chatBoxScrolledDown', { 'chatbox': this.model });
|
|
|
+ api.trigger("chatBoxScrolledDown", { chatbox: this.model });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-api.elements.define('converse-chat-content', ChatContent);
|
|
|
+api.elements.define("converse-chat-content", ChatContent);
|