Browse Source

Use intersection observer to remember scrolling position

JC Brand 4 years ago
parent
commit
58d96c8594

+ 5 - 2
src/headless/utils/core.js

@@ -362,13 +362,16 @@ u.onMultipleEvents = function (events=[], callback) {
     events.forEach(e => e.object.on(e.event, handler));
 };
 
-u.safeSave = function (model, attributes, options) {
+
+export function safeSave (model, attributes, options) {
     if (u.isPersistableModel(model)) {
         model.save(attributes, options);
     } else {
         model.set(attributes, options);
     }
-};
+}
+
+u.safeSave = safeSave;
 
 u.siblingIndex = function (el) {
     /* eslint-disable no-cond-assign */

+ 1 - 1
src/plugins/chatview/bottom-panel.js

@@ -84,7 +84,7 @@ export default class ChatBottomPanel extends ElementView {
 
     viewUnreadMessages (ev) {
         ev?.preventDefault?.();
-        this.model.save({ 'scrolled': false, 'scrollTop': null });
+        this.model.save({ 'scrolled': false });
     }
 
     onMessageCorrecting (message) {

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

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

+ 1 - 6
src/plugins/chatview/view.js

@@ -22,10 +22,7 @@ export default class ChatView extends BaseChatView {
     async initialize () {
         const jid = this.getAttribute('jid');
         _converse.chatboxviews.add(jid, this);
-
         this.model = _converse.chatboxes.get(jid);
-        this.initDebounced();
-
         this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
         this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown());
         this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
@@ -46,9 +43,7 @@ export default class ChatView extends BaseChatView {
     }
 
     render () {
-        const result = tpl_chat(Object.assign(
-            this.model.toJSON(), { 'markScrolled': ev => this.markScrolled(ev) })
-        );
+        const result = tpl_chat(this.model.toJSON());
         render(result, this);
         this.help_container = this.querySelector('.chat-content__help');
         return this;

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

@@ -9,8 +9,7 @@ export default (o) => html`
             <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
                 <converse-chat-content
                     class="chat-content__messages"
-                    jid="${o.jid}"
-                    @scroll=${o.markScrolled}></converse-chat-content>
+                    jid="${o.jid}"></converse-chat-content>
 
                 <div class="chat-content__help"></div>
             </div>

+ 0 - 2
src/plugins/headlines-view/view.js

@@ -11,8 +11,6 @@ class HeadlinesView extends BaseChatView {
         _converse.chatboxviews.add(jid, this);
 
         this.model = _converse.chatboxes.get(jid);
-        this.initDebounced();
-
         this.model.disable_mam = true; // Don't do MAM queries for this box
         this.listenTo(this.model, 'change:hidden', () => this.afterShown());
         this.listenTo(this.model, 'destroy', this.remove);

+ 2 - 1
src/plugins/mam-views/utils.js

@@ -14,10 +14,11 @@ export async function fetchMessagesOnScrollUp (view) {
             } else {
                 await fetchArchivedMessages(view.model, { 'end': oldest_message.get('time') });
             }
-            view.model.ui.set('chat-content-spinner-top', false);
             if (api.settings.get('allow_url_history_change')) {
                 _converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
             }
+
+            setTimeout(() => view.model.ui.set('chat-content-spinner-top', false), 250);
         }
     }
 }

+ 0 - 3
src/plugins/minimize/utils.js

@@ -151,9 +151,6 @@ export function minimize (ev, model) {
     } else {
         model = ev;
     }
-    // save the scroll position to restore it on maximize
-    const view = _converse.chatboxviews.get(model.get('jid'));
-    view.querySelector('.chat-content__messages')?.saveScrollPosition();
     model.setChatState(_converse.INACTIVE);
     u.safeSave(model, {
         'hidden': true,

+ 7 - 30
src/plugins/muc-views/chatarea.js

@@ -3,6 +3,8 @@ import tpl_muc_chatarea from './templates/muc-chatarea.js';
 import { CustomElement } from 'shared/components/element.js';
 import { __ } from 'i18n';
 import { _converse, api, converse } from '@converse/headless/core';
+import { onScrolledDown } from 'shared/chat/utils.js';
+import { safeSave } from '@converse/headless/utils/core.js';
 
 
 const { u } = converse.env;
@@ -93,17 +95,13 @@ export default class MUCChatArea extends CustomElement {
      * which debounces this method by 100ms.
      * @private
      */
-    _markScrolled (ev) {
+    _markScrolled () {
         let scrolled = true;
-        let scrollTop = null;
-        const msgs_container = this.querySelector('.chat-content__messages');
-        const is_at_bottom =
-            msgs_container.scrollTop + msgs_container.clientHeight >= msgs_container.scrollHeight - 62; // sigh...
-
+        const is_at_bottom = this.scrollTop + this.clientHeight >= this.scrollHeight;
         if (is_at_bottom) {
             scrolled = false;
-            this.onScrolledDown();
-        } else if (msgs_container.scrollTop === 0) {
+            onScrolledDown(this.model);
+        } else if (this.scrollTop === 0) {
             /**
              * Triggered once the chat's message area has been scrolled to the top
              * @event _converse#chatBoxScrolledUp
@@ -111,29 +109,8 @@ export default class MUCChatArea extends CustomElement {
              * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
              */
             api.trigger('chatBoxScrolledUp', this);
-        } else {
-            scrollTop = ev.target.scrollTop;
-        }
-        u.safeSave(this.model, { scrolled, scrollTop });
-    }
-
-    onScrolledDown () {
-        if (!this.model.isHidden()) {
-            this.model.clearUnreadMsgCounter();
-            if (api.settings.get('allow_url_history_change')) {
-                // Clear location hash if set to one of the messages in our history
-                const hash = window.location.hash;
-                hash && this.model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
-            }
         }
-        /**
-         * Triggered once the chat's message area has been scrolled down to the bottom.
-         * @event _converse#chatBoxScrolledDown
-         * @type {object}
-         * @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
-         * @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... });
-         */
-        api.trigger('chatBoxScrolledDown', { 'chatbox': this.model });
+        safeSave(this.model, { scrolled });
     }
 
     onMousedown (ev) {

+ 0 - 2
src/plugins/muc-views/muc.js

@@ -14,8 +14,6 @@ export default class MUCView extends BaseChatView {
         const jid = this.getAttribute('jid');
         this.model = await api.rooms.get(jid);
         _converse.chatboxviews.add(jid, this);
-        this.initDebounced();
-
         this.setAttribute('id', this.model.get('box_id'));
 
         this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);

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

@@ -10,8 +10,7 @@ export default (o) => html`
         <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
             <converse-chat-content
                 class="chat-content__messages"
-                jid="${o.jid}"
-                @scroll=${o.markScrolled}></converse-chat-content>
+                jid="${o.jid}"></converse-chat-content>
 
             ${o.show_help_messages ? html`<div class="chat-content__help">
                     <converse-chat-help

+ 3 - 53
src/shared/chat/baseview.js

@@ -1,16 +1,12 @@
-import debounce from 'lodash-es/debounce';
 import log from '@converse/headless/log';
 import { ElementView } from '@converse/skeletor/src/element.js';
 import { _converse, api, converse } from '@converse/headless/core';
+import { onScrolledDown } from './utils.js';
 
 const u = converse.env.utils;
 
 export default class BaseChatView extends ElementView {
 
-    initDebounced () {
-        this.markScrolled = debounce(this._markScrolled, 100);
-    }
-
     disconnectedCallback () {
         super.disconnectedCallback();
         const jid = this.getAttribute('jid');
@@ -93,38 +89,6 @@ export default class BaseChatView extends ElementView {
         }
     }
 
-    /**
-     * 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.
-     *
-     * Don't call this method directly, instead, call `markScrolled`,
-     * which debounces this method by 100ms.
-     * @private
-     */
-    _markScrolled (ev) {
-        let scrolled = true;
-        let scrollTop = null;
-        const is_at_bottom = ev.target.scrollTop + ev.target.clientHeight >= ev.target.scrollHeight - 62; // sigh...
-        if (is_at_bottom) {
-            scrolled = false;
-            this.onScrolledDown();
-        } else if (ev.target.scrollTop === 0) {
-            scrollTop = ev.target.scrollTop;
-            /**
-             * Triggered once the chat's message area has been scrolled to the top
-             * @event _converse#chatBoxScrolledUp
-             * @property { _converse.ChatBoxView | _converse.ChatRoomView } view
-             * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
-             */
-            api.trigger('chatBoxScrolledUp', this);
-        } else {
-            scrollTop = ev.target.scrollTop;
-        }
-        u.safeSave(this.model, { scrolled, scrollTop });
-    }
-
     /**
      * Scrolls the chat down.
      *
@@ -136,23 +100,9 @@ export default class BaseChatView extends ElementView {
         ev?.preventDefault?.();
         ev?.stopPropagation?.();
         if (this.model.get('scrolled')) {
-            u.safeSave(this.model, {
-                'scrolled': false,
-                'scrollTop': null
-            });
-        }
-        this.onScrolledDown();
-    }
-
-    onScrolledDown () {
-        if (!this.model.isHidden()) {
-            this.model.clearUnreadMsgCounter();
-            if (api.settings.get('allow_url_history_change')) {
-                // Clear location hash if set to one of the messages in our history
-                const hash = window.location.hash;
-                hash && this.model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
-            }
+            u.safeSave(this.model, { 'scrolled': false });
         }
+        onScrolledDown(this.model);
     }
 
     onWindowStateChanged (data) {

+ 58 - 10
src/shared/chat/chat-content.js

@@ -1,10 +1,11 @@
 import './message-history';
 import debounce from 'lodash/debounce';
 import { CustomElement } from 'shared/components/element.js';
-import { _converse, api, converse } from '@converse/headless/core';
+import { _converse, api } from '@converse/headless/core';
 import { html } from 'lit';
+import { onScrolledDown } from './utils.js';
+import { safeSave } from '@converse/headless/utils/core.js';
 
-const { u } = converse;
 
 export default class ChatContent extends CustomElement {
 
@@ -17,6 +18,7 @@ export default class ChatContent extends CustomElement {
     connectedCallback () {
         super.connectedCallback();
         this.debouncedMaintainScroll = debounce(this.maintainScrollPosition, 100);
+        this.markScrolled = debounce(this._markScrolled, 100);
 
         this.model = _converse.chatboxes.get(this.jid);
         this.listenTo(this.model, 'change:hidden_occupants', this.requestUpdate);
@@ -40,6 +42,8 @@ export default class ChatContent extends CustomElement {
         this.addEventListener('imageLoaded', () => {
             this.debouncedMaintainScroll(this.was_scrolled_up);
         });
+        this.addEventListener('scroll', () => this.markScrolled());
+        this.initIntersectionObserver();
     }
 
     render () {
@@ -47,6 +51,7 @@ export default class ChatContent extends CustomElement {
             ${ this.model.ui.get('chat-content-spinner-top') ? html`<span class="spinner fa fa-spinner centered"></span>` : '' }
             <converse-message-history
                 .model=${this.model}
+                .observer=${this.observer}
                 .messages=${[...this.model.messages.models]}>
             </converse-message-history>
             <div class="chat-content__notifications">${this.model.getNotificationsText()}</div>
@@ -58,19 +63,62 @@ export default class ChatContent extends CustomElement {
         this.debouncedMaintainScroll();
     }
 
-    saveScrollPosition () {
-        const scrollTop = this.scrollTop;
-        if (scrollTop) {
-            u.safeSave(this.model, { 'scrolled': true, scrollTop });
+    initIntersectionObserver () {
+      if (this.observer) {
+          this.observer.disconnect();
+      } else {
+          const options = {
+              root: this,
+              threshold: [0.1]
+          }
+          const handler = ev => this.setAnchoredMessage(ev);
+          this.observer = new IntersectionObserver(handler, options);
+      }
+    }
+
+    /**
+     * 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.
+     *
+     * Don't call this method directly, instead, call `markScrolled`,
+     * which debounces this method by 100ms.
+     * @private
+     */
+    _markScrolled () {
+        let scrolled = true;
+        const is_at_bottom = this.scrollTop + this.clientHeight >= this.scrollHeight;
+        if (is_at_bottom) {
+            scrolled = false;
+            onScrolledDown(this.model);
+        } else if (this.scrollTop === 0) {
+            /**
+             * Triggered once the chat's message area has been scrolled to the top
+             * @event _converse#chatBoxScrolledUp
+             * @property { _converse.ChatBoxView | _converse.ChatRoomView } view
+             * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
+             */
+            api.trigger('chatBoxScrolledUp', this);
+        }
+        safeSave(this.model, { scrolled });
+    }
+
+    setAnchoredMessage (entries) {
+        if (this.model.ui.get('chat-content-spinner-top')) {
+            return;
+        }
+        entries = entries.filter(e => e.isIntersecting);
+        const current = entries.reduce((p, c) => c.boundingClientRect.y >= (p?.boundingClientRect.y || 0) ? c : p, null);
+        if (current) {
+            this.anchored_message = current.target;
         }
     }
 
     maintainScrollPosition () {
         if (this.was_scrolled_up) {
-            const pos = this.model.get('scrollTop');
-            if (pos) {
-                this.scrollTop = pos;
-            }
+            console.warn('scrolling into view');
+            this.anchored_message?.scrollIntoView(true);
         } else {
             this.scrollDown();
         }

+ 3 - 3
src/shared/chat/emoji-picker-content.js

@@ -54,16 +54,16 @@ export default class EmojiPickerContent extends CustomElement {
       sizzle('.emoji-picker', this).forEach(a => this.observer.observe(a));
   }
 
-  setCategoryOnVisibilityChange (ev) {
+  setCategoryOnVisibilityChange (entries) {
       const selected = this.parentElement.navigator.selected;
-      const intersection_with_selected = ev.filter(i => i.target.contains(selected)).pop();
+      const intersection_with_selected = entries.filter(i => i.target.contains(selected)).pop();
       let current;
       // Choose the intersection that contains the currently selected
       // element, or otherwise the one with the largest ratio.
       if (intersection_with_selected) {
           current = intersection_with_selected;
       } else {
-          current = ev.reduce((p, c) => c.intersectionRatio >= (p?.intersectionRatio || 0) ? c : p, null);
+          current = entries.reduce((p, c) => c.intersectionRatio >= (p?.intersectionRatio || 0) ? c : p, null);
       }
       if (current && current.isIntersecting) {
           const category = current.target.getAttribute('data-category');

+ 4 - 2
src/shared/chat/message-history.js

@@ -50,8 +50,9 @@ export default class MessageHistory extends CustomElement {
 
     static get properties () {
         return {
-            model: { type: Object},
-            messages: { type: Array}
+            model: { type: Object },
+            observer: { type: Object },
+            messages: { type: Array }
         }
     }
 
@@ -67,6 +68,7 @@ export default class MessageHistory extends CustomElement {
         const day = getDayIndicator(model);
         const templates = day ? [day] : [];
         const message = html`<converse-chat-message
+            .observer=${this.observer}
             jid="${this.model.get('jid')}"
             mid="${model.get('id')}"></converse-chat-message>`
 

+ 6 - 1
src/shared/chat/message.js

@@ -24,7 +24,8 @@ export default class Message extends CustomElement {
     static get properties () {
         return {
             jid: { type: String },
-            mid: { type: String }
+            mid: { type: String },
+            observer: { type: Object }
         }
     }
 
@@ -59,6 +60,10 @@ export default class Message extends CustomElement {
         }
     }
 
+    firstUpdated () {
+        this.observer.observe(this);
+    }
+
     getProps () {
         return Object.assign(
             this.model.toJSON(),

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

@@ -0,0 +1,12 @@
+import { _converse, api } from '@converse/headless/core';
+
+export function onScrolledDown (model) {
+if (!model.isHidden()) {
+    model.clearUnreadMsgCounter();
+    if (api.settings.get('allow_url_history_change')) {
+        // Clear location hash if set to one of the messages in our history
+        const hash = window.location.hash;
+        hash && model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
+    }
+}
+}