Переглянути джерело

feature: support a virtualized chat history

Fixes #2335
JC Brand 4 місяців тому
батько
коміт
ccc4afea33

+ 1 - 0
CHANGES.md

@@ -18,6 +18,7 @@
 - #1700: Deleted pending contacts reappear after page reload
 - #1810: Create clickable link to load older MAM messages if there is no scrollbars.
 - #2118: Show reflected message in MUC 
+- #2335: Support list virtualization of the message history
 - #2383: Add modal to start chats with JIDs not in the roster
 - #2586: Add support for XEP-0402 Bookmarks
 - #2623: Merge MUC join and bookmark, leave and unset autojoin 

+ 0 - 1
dev.html

@@ -31,7 +31,6 @@
         auto_away: 300,
         enable_smacks: true,
         loglevel: 'debug',
-        prune_messages_above: 100,
         show_background: true,
         message_archiving: 'always',
         muc_respect_autojoin: true,

+ 8 - 3
docs/source/configuration.rst

@@ -1757,8 +1757,14 @@ that number. As new messages come in, older messages will be deleted to
 maintain the history size.
 
 .. note::
-  When deleting locally stored decrypted OMEMO messages, you will **not** be
-  able to decrypt them again after fetching them from the server archive.
+    When deleting locally stored decrypted OMEMO messages, you will **not** be
+    able to decrypt them again after fetching them from the server archive.
+
+.. note::
+    This feature was implemented before we had list virtualization for the chat
+    history (release in version 11) and was largely a backstop until that
+    feature became available. This feature can therefore be considered
+    DEPRECATED and will likely be removed in future versions.
 
 pruning_behavior
 ----------------
@@ -1774,7 +1780,6 @@ scrolled up. Be aware that this will interfere with MAM-based infinite
 scrolling, and this setting only makes sense when infinite scrolling with MAM
 is disabled.
 
-
 push_app_servers
 ----------------
 

+ 6 - 3
src/headless/index.js

@@ -8,6 +8,12 @@ import u from './utils/index.js';
 import converse from './shared/api/public.js';
 import log from './log.js';
 
+import BaseMessage from './shared/message.js';
+export { BaseMessage };
+
+import ModelWithMessages from './shared/model-with-messages.js';
+export { ModelWithMessages };
+
 // START: Removable components
 // ---------------------------
 // The following components may be removed if they're not needed.
@@ -22,9 +28,6 @@ import './plugins/disco/index.js'; // XEP-0030 Service discovery
 import './plugins/adhoc/index.js'; // XEP-0050 Ad Hoc Commands
 import './plugins/headlines/index.js'; // Support for headline messages
 
-import ModelWithMessages from './shared/model-with-messages.js';
-export { ModelWithMessages };
-
 // XEP-0313 Message Archive Management
 export { MAMPlaceholderMessage } from './plugins/mam/index.js';
 

+ 1 - 4
src/headless/shared/model-with-messages.js

@@ -968,11 +968,8 @@ export default function ModelWithMessages(BaseModel) {
                          * Triggered once the message history has been pruned, i.e.
                          * once older messages have been removed to keep the
                          * number of messages below the value set in `prune_messages_above`.
-                         * @event _converse#historyPruned
-                         * @type { ChatBox | MUC }
-                         * @example _converse.api.listen.on('historyPruned', this => { ... });
                          */
-                        api.trigger('historyPruned', this);
+                        this.trigger('historyPruned');
                     }
                 }
             }

+ 2 - 1
src/headless/types/index.d.ts

@@ -2,6 +2,7 @@ export { EmojiPicker } from "./plugins/emoji/index.js";
 export { MAMPlaceholderMessage } from "./plugins/mam/index.js";
 export { XMPPStatus } from "./plugins/status/index.js";
 export default converse;
+import BaseMessage from './shared/message.js';
 import ModelWithMessages from './shared/model-with-messages.js';
 import { api } from './shared/index.js';
 import converse from './shared/api/public.js';
@@ -14,7 +15,7 @@ import { parsers } from './shared/index.js';
 import * as errors from './shared/errors.js';
 import { constants as shared_constants } from './shared/index.js';
 import * as muc_constants from './plugins/muc/constants.js';
-export { ModelWithMessages, api, converse, _converse, i18n, log, u, parsers, errors };
+export { BaseMessage, ModelWithMessages, api, converse, _converse, i18n, log, u, parsers, errors };
 export { Bookmark, Bookmarks } from "./plugins/bookmarks/index.js";
 export { ChatBox, Message, Messages } from "./plugins/chat/index.js";
 export { MUCMessage, MUCMessages, MUC, MUCOccupant, MUCOccupants } from "./plugins/muc/index.js";

+ 14 - 14
src/headless/types/plugins/chat/model.d.ts

@@ -87,20 +87,20 @@ declare const ChatBox_base: {
         fetchMessages(): any;
         afterMessagesFetched(): void;
         onMessage(_attrs_or_error: import("../../shared/types").MessageAttributes | Error): Promise<void>;
-        getUpdatedMessageAttributes(message: import("../../shared/message.js").default<any>, attrs: import("../../shared/types").MessageAttributes): object;
-        updateMessage(message: import("../../shared/message.js").default<any>, attrs: import("../../shared/types").MessageAttributes): void;
-        handleCorrection(attrs: import("../../shared/types").MessageAttributes | import("../muc/types.js").MUCMessageAttributes): Promise<import("../../shared/message.js").default<any> | void>;
+        getUpdatedMessageAttributes(message: import("../../index.js").BaseMessage<any>, attrs: import("../../shared/types").MessageAttributes): object;
+        updateMessage(message: import("../../index.js").BaseMessage<any>, attrs: import("../../shared/types").MessageAttributes): void;
+        handleCorrection(attrs: import("../../shared/types").MessageAttributes | import("../muc/types.js").MUCMessageAttributes): Promise<import("../../index.js").BaseMessage<any> | void>;
         queueMessage(attrs: import("../../shared/types").MessageAttributes): any;
         msg_chain: any;
         getOutgoingMessageAttributes(_attrs?: import("../../shared/types").MessageAttributes): Promise<import("../../shared/types").MessageAttributes>;
-        sendMessage(attrs?: any): Promise<import("../../shared/message.js").default<any>>;
-        retractOwnMessage(message: import("../../shared/message.js").default<any>): void;
+        sendMessage(attrs?: any): Promise<import("../../index.js").BaseMessage<any>>;
+        retractOwnMessage(message: import("../../index.js").BaseMessage<any>): void;
         sendFiles(files: File[]): Promise<void>;
         setEditable(attrs: any, send_time: string): void;
         setChatState(state: string, options?: object): any;
         chat_state_timeout: NodeJS.Timeout;
-        onMessageAdded(message: import("../../shared/message.js").default<any>): void;
-        onMessageUploadChanged(message: import("../../shared/message.js").default<any>): Promise<void>;
+        onMessageAdded(message: import("../../index.js").BaseMessage<any>): void;
+        onMessageUploadChanged(message: import("../../index.js").BaseMessage<any>): Promise<void>;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         shouldShowErrorMessage(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;
@@ -110,8 +110,8 @@ declare const ChatBox_base: {
         getOldestMessage(): any;
         getMostRecentMessage(): any;
         getMessageReferencedByError(attrs: object): any;
-        findDanglingRetraction(attrs: object): import("../../shared/message.js").default<any> | null;
-        getDuplicateMessage(attrs: object): import("../../shared/message.js").default<any>;
+        findDanglingRetraction(attrs: object): import("../../index.js").BaseMessage<any> | null;
+        getDuplicateMessage(attrs: object): import("../../index.js").BaseMessage<any>;
         getOriginIdQueryAttrs(attrs: object): {
             origin_id: any;
             from: any;
@@ -121,15 +121,15 @@ declare const ChatBox_base: {
             from: any;
             msgid: any;
         };
-        sendMarkerForMessage(msg: import("../../shared/message.js").default<any>, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
-        handleUnreadMessage(message: import("../../shared/message.js").default<any>): void;
-        getErrorAttributesForMessage(message: import("../../shared/message.js").default<any>, attrs: import("../../shared/types").MessageAttributes): Promise<any>;
+        sendMarkerForMessage(msg: import("../../index.js").BaseMessage<any>, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
+        handleUnreadMessage(message: import("../../index.js").BaseMessage<any>): void;
+        getErrorAttributesForMessage(message: import("../../index.js").BaseMessage<any>, attrs: import("../../shared/types").MessageAttributes): Promise<any>;
         handleErrorMessageStanza(stanza: Element): Promise<void>;
-        incrementUnreadMsgsCounter(message: import("../../shared/message.js").default<any>): void;
+        incrementUnreadMsgsCounter(message: import("../../index.js").BaseMessage<any>): void;
         clearUnreadMsgCounter(): void;
         handleRetraction(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;
         handleReceipt(attrs: import("../../shared/types").MessageAttributes): boolean;
-        createMessageStanza(message: import("../../shared/message.js").default<any>): Promise<any>;
+        createMessageStanza(message: import("../../index.js").BaseMessage<any>): Promise<any>;
         pruneHistory(): void;
         debouncedPruneHistory: import("lodash").DebouncedFunc<() => void>;
         isScrolledUp(): any;

+ 14 - 14
src/headless/types/plugins/muc/occupant.d.ts

@@ -87,20 +87,20 @@ declare const MUCOccupant_base: {
         fetchMessages(): any;
         afterMessagesFetched(): void;
         onMessage(_attrs_or_error: import("../../shared/types").MessageAttributes | Error): Promise<void>;
-        getUpdatedMessageAttributes(message: import("../../shared/message").default<any>, attrs: import("../../shared/types").MessageAttributes): object;
-        updateMessage(message: import("../../shared/message").default<any>, attrs: import("../../shared/types").MessageAttributes): void;
-        handleCorrection(attrs: import("../../shared/types").MessageAttributes | import("./types").MUCMessageAttributes): Promise<import("../../shared/message").default<any> | void>;
+        getUpdatedMessageAttributes(message: import("../..").BaseMessage<any>, attrs: import("../../shared/types").MessageAttributes): object;
+        updateMessage(message: import("../..").BaseMessage<any>, attrs: import("../../shared/types").MessageAttributes): void;
+        handleCorrection(attrs: import("../../shared/types").MessageAttributes | import("./types").MUCMessageAttributes): Promise<import("../..").BaseMessage<any> | void>;
         queueMessage(attrs: import("../../shared/types").MessageAttributes): any;
         msg_chain: any;
         getOutgoingMessageAttributes(_attrs?: import("../../shared/types").MessageAttributes): Promise<import("../../shared/types").MessageAttributes>;
-        sendMessage(attrs?: any): Promise<import("../../shared/message").default<any>>;
-        retractOwnMessage(message: import("../../shared/message").default<any>): void;
+        sendMessage(attrs?: any): Promise<import("../..").BaseMessage<any>>;
+        retractOwnMessage(message: import("../..").BaseMessage<any>): void;
         sendFiles(files: File[]): Promise<void>;
         setEditable(attrs: any, send_time: string): void;
         setChatState(state: string, options?: object): any;
         chat_state_timeout: NodeJS.Timeout;
-        onMessageAdded(message: import("../../shared/message").default<any>): void;
-        onMessageUploadChanged(message: import("../../shared/message").default<any>): Promise<void>;
+        onMessageAdded(message: import("../..").BaseMessage<any>): void;
+        onMessageUploadChanged(message: import("../..").BaseMessage<any>): Promise<void>;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         shouldShowErrorMessage(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;
@@ -110,8 +110,8 @@ declare const MUCOccupant_base: {
         getOldestMessage(): any;
         getMostRecentMessage(): any;
         getMessageReferencedByError(attrs: object): any;
-        findDanglingRetraction(attrs: object): import("../../shared/message").default<any> | null;
-        getDuplicateMessage(attrs: object): import("../../shared/message").default<any>;
+        findDanglingRetraction(attrs: object): import("../..").BaseMessage<any> | null;
+        getDuplicateMessage(attrs: object): import("../..").BaseMessage<any>;
         getOriginIdQueryAttrs(attrs: object): {
             origin_id: any;
             from: any;
@@ -121,15 +121,15 @@ declare const MUCOccupant_base: {
             from: any;
             msgid: any;
         };
-        sendMarkerForMessage(msg: import("../../shared/message").default<any>, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
-        handleUnreadMessage(message: import("../../shared/message").default<any>): void;
-        getErrorAttributesForMessage(message: import("../../shared/message").default<any>, attrs: import("../../shared/types").MessageAttributes): Promise<any>;
+        sendMarkerForMessage(msg: import("../..").BaseMessage<any>, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
+        handleUnreadMessage(message: import("../..").BaseMessage<any>): void;
+        getErrorAttributesForMessage(message: import("../..").BaseMessage<any>, attrs: import("../../shared/types").MessageAttributes): Promise<any>;
         handleErrorMessageStanza(stanza: Element): Promise<void>;
-        incrementUnreadMsgsCounter(message: import("../../shared/message").default<any>): void;
+        incrementUnreadMsgsCounter(message: import("../..").BaseMessage<any>): void;
         clearUnreadMsgCounter(): void;
         handleRetraction(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;
         handleReceipt(attrs: import("../../shared/types").MessageAttributes): boolean;
-        createMessageStanza(message: import("../../shared/message").default<any>): Promise<any>;
+        createMessageStanza(message: import("../..").BaseMessage<any>): Promise<any>;
         pruneHistory(): void;
         debouncedPruneHistory: import("lodash").DebouncedFunc<() => void>;
         isScrolledUp(): any;

+ 3 - 0
src/plugins/chatview/styles/chatbox.scss

@@ -171,6 +171,9 @@
             }
 
             .chat-content__messages {
+                display: flex;
+                flex-direction: column-reverse;
+                justify-content: space-between;
                 overflow-x: hidden;
                 overflow-y: auto;
                 height: 100%;

+ 0 - 1
src/plugins/chatview/templates/chat.js

@@ -11,7 +11,6 @@ export default (o) => html`
             <div class="chat-body">
                 <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
                     <converse-chat-content
-                        class="chat-content__messages"
                         .model="${o.model}"></converse-chat-content>
 
                     ${o.show_help_messages ? html`<div class="chat-content__help">

+ 1 - 1
src/plugins/chatview/tests/chatbox.js

@@ -495,7 +495,7 @@ describe("Chatboxes", function () {
                     api.connection.get()._dataRecv(mock.createRequest(msg));
                     const view = _converse.chatboxviews.get(sender_jid);
                     let csn = mock.cur_names[1] + ' is typing';
-                    await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === csn);
+                    await u.waitUntil( () => view.querySelector('.chat-content__notifications')?.innerText === csn);
                     expect(view.model.messages.length).toEqual(0);
 
                     // <paused> state

+ 0 - 1
src/plugins/headlines-view/templates/headlines.js

@@ -10,7 +10,6 @@ export default (model) => html`
             <div class="chat-body">
                 <div class="chat-content" aria-live="polite">
                     <converse-chat-content
-                        class="chat-content__messages"
                         .model=${model}></converse-chat-content>
                 </div>
             </div>` : '' }

+ 0 - 1
src/plugins/muc-views/templates/muc-chatarea.js

@@ -29,7 +29,6 @@ export default (el) => {
         <div class="chat-area ${el.shouldShowSidebar() ? chat_area_classes : 'col-xs-12' }">
             <div class="chat-content ${show_send_button ? 'chat-content-sendbutton' : ''}" aria-live="polite">
                 <converse-muc-chat-content
-                    class="chat-content__messages"
                     .model="${el.model}"></converse-muc-chat-content>
 
                 ${(el.model?.get('show_help_messages')) ?

+ 0 - 1
src/plugins/muc-views/templates/muc-occupant.js

@@ -85,7 +85,6 @@ export default (el) => {
             ? html`<div class="chat-body">
                   <div class="chat-content chat-content-sendbutton" aria-live="polite">
                       <converse-chat-content
-                          class="chat-content__messages"
                           .model="${el.model}"
                       ></converse-chat-content>
                   </div>

+ 147 - 46
src/shared/chat/chat-content.js

@@ -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);

+ 0 - 39
src/shared/chat/utils.js

@@ -1,14 +1,10 @@
 /**
  * @typedef {import('plugins/chatview/types').HeadingButtonAttributes} HeadingButtonAttributes
- * @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
  * @typedef {import('@converse/headless').Message} Message
  * @typedef {import('@converse/headless').MUCMessage} MUCMessage
  * @typedef {import('@converse/skeletor').Model} Model
  * @typedef {import('lit').TemplateResult} TemplateResult
  */
-import debounce from 'lodash-es/debounce';
 import { api, converse } from '@converse/headless';
 import { html } from 'lit';
 import { until } from 'lit/directives/until.js';
@@ -100,41 +96,6 @@ export function onScrolledDown (model) {
     }
 }
 
-/**
- * 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.
- */
-export const markScrolled = debounce(
-    /** @param {Event} ev */
-    function _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(el.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 (el.model.get('scolled') !== scrolled) {
-            el.model.ui.set({ scrolled });
-        }
-    },
-    50
-);
-
 /**
  * Given a message object, returns a TemplateResult indicating a new day if
  * the passed in message is more than a day later than its predecessor.

+ 18 - 2
src/types/shared/chat/chat-content.d.ts

@@ -1,14 +1,30 @@
+/**
+ * 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 {
     static get properties(): {
         model: {
             type: ObjectConstructor;
         };
+        window_top: {
+            state: boolean;
+        };
+        window_bottom: {
+            state: boolean;
+        };
     };
     model: any;
-    connectedCallback(): void;
+    scroll_debounce: any;
+    window_top: number;
+    window_bottom: number;
+    scrollHandler: (ev: Event) => void;
+    mark_scrolled_debounce: NodeJS.Timeout;
     initialize(): Promise<void>;
     render(): import("lit").TemplateResult<1> | "";
     scrollDown(): void;
+    #private;
 }
-import { CustomElement } from '../components/element.js';
+import { CustomElement } from "../components/element.js";
 //# sourceMappingURL=chat-content.d.ts.map

+ 0 - 10
src/types/shared/chat/utils.d.ts

@@ -78,21 +78,11 @@ export function shortnamesToEmojis(str: string, options?: {
     unicode_only: boolean;
     add_title_wrapper: boolean;
 }): any[];
-/**
- * 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.
- */
-export const markScrolled: import("lodash").DebouncedFunc<(ev: Event) => void>;
 export type EmojiMarkupOptions = {
     unicode_only?: boolean;
     add_title_wrapper?: boolean;
 };
 export type HeadingButtonAttributes = import("plugins/chatview/types").HeadingButtonAttributes;
-export type ChatView = import("../../plugins/chatview/chat.js").default;
-export type MUCView = import("../../plugins/muc-views/muc.js").default;
-export type MUCOccupantView = import("../../plugins/muc-views/occupant").default;
 export type Message = import("@converse/headless").Message;
 export type MUCMessage = import("@converse/headless").MUCMessage;
 export type Model = import("@converse/skeletor").Model;