Use intersection observer to remember scrolling position

This commit is contained in:
JC Brand 2021-06-03 16:29:31 +02:00
parent 279a3c3413
commit 58d96c8594
17 changed files with 105 additions and 122 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -151,9 +151,6 @@ export function minimize (ev, model) {
} else { } else {
model = ev; 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); model.setChatState(_converse.INACTIVE);
u.safeSave(model, { u.safeSave(model, {
'hidden': true, 'hidden': true,

View File

@ -3,6 +3,8 @@ import tpl_muc_chatarea from './templates/muc-chatarea.js';
import { CustomElement } from 'shared/components/element.js'; import { CustomElement } from 'shared/components/element.js';
import { __ } from 'i18n'; import { __ } from 'i18n';
import { _converse, api, converse } from '@converse/headless/core'; 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; const { u } = converse.env;
@ -93,17 +95,13 @@ export default class MUCChatArea extends CustomElement {
* which debounces this method by 100ms. * which debounces this method by 100ms.
* @private * @private
*/ */
_markScrolled (ev) { _markScrolled () {
let scrolled = true; let scrolled = true;
let scrollTop = null; const is_at_bottom = this.scrollTop + this.clientHeight >= this.scrollHeight;
const msgs_container = this.querySelector('.chat-content__messages');
const is_at_bottom =
msgs_container.scrollTop + msgs_container.clientHeight >= msgs_container.scrollHeight - 62; // sigh...
if (is_at_bottom) { if (is_at_bottom) {
scrolled = false; scrolled = false;
this.onScrolledDown(); onScrolledDown(this.model);
} else if (msgs_container.scrollTop === 0) { } else if (this.scrollTop === 0) {
/** /**
* Triggered once the chat's message area has been scrolled to the top * Triggered once the chat's message area has been scrolled to the top
* @event _converse#chatBoxScrolledUp * @event _converse#chatBoxScrolledUp
@ -111,29 +109,8 @@ export default class MUCChatArea extends CustomElement {
* @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... }); * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
*/ */
api.trigger('chatBoxScrolledUp', this); api.trigger('chatBoxScrolledUp', this);
} else {
scrollTop = ev.target.scrollTop;
} }
u.safeSave(this.model, { scrolled, scrollTop }); safeSave(this.model, { scrolled });
}
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 });
} }
onMousedown (ev) { onMousedown (ev) {

View File

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

View File

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

View File

@ -1,16 +1,12 @@
import debounce from 'lodash-es/debounce';
import log from '@converse/headless/log'; import log from '@converse/headless/log';
import { ElementView } from '@converse/skeletor/src/element.js'; import { ElementView } from '@converse/skeletor/src/element.js';
import { _converse, api, converse } from '@converse/headless/core'; import { _converse, api, converse } from '@converse/headless/core';
import { onScrolledDown } from './utils.js';
const u = converse.env.utils; const u = converse.env.utils;
export default class BaseChatView extends ElementView { export default class BaseChatView extends ElementView {
initDebounced () {
this.markScrolled = debounce(this._markScrolled, 100);
}
disconnectedCallback () { disconnectedCallback () {
super.disconnectedCallback(); super.disconnectedCallback();
const jid = this.getAttribute('jid'); 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. * Scrolls the chat down.
* *
@ -136,23 +100,9 @@ export default class BaseChatView extends ElementView {
ev?.preventDefault?.(); ev?.preventDefault?.();
ev?.stopPropagation?.(); ev?.stopPropagation?.();
if (this.model.get('scrolled')) { if (this.model.get('scrolled')) {
u.safeSave(this.model, { u.safeSave(this.model, { 'scrolled': false });
'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();
}
} }
onScrolledDown(this.model);
} }
onWindowStateChanged (data) { onWindowStateChanged (data) {

View File

@ -1,10 +1,11 @@
import './message-history'; import './message-history';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { CustomElement } from 'shared/components/element.js'; 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 { 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 { export default class ChatContent extends CustomElement {
@ -17,6 +18,7 @@ export default class ChatContent extends CustomElement {
connectedCallback () { connectedCallback () {
super.connectedCallback(); super.connectedCallback();
this.debouncedMaintainScroll = debounce(this.maintainScrollPosition, 100); this.debouncedMaintainScroll = debounce(this.maintainScrollPosition, 100);
this.markScrolled = debounce(this._markScrolled, 100);
this.model = _converse.chatboxes.get(this.jid); this.model = _converse.chatboxes.get(this.jid);
this.listenTo(this.model, 'change:hidden_occupants', this.requestUpdate); this.listenTo(this.model, 'change:hidden_occupants', this.requestUpdate);
@ -40,6 +42,8 @@ export default class ChatContent extends CustomElement {
this.addEventListener('imageLoaded', () => { this.addEventListener('imageLoaded', () => {
this.debouncedMaintainScroll(this.was_scrolled_up); this.debouncedMaintainScroll(this.was_scrolled_up);
}); });
this.addEventListener('scroll', () => this.markScrolled());
this.initIntersectionObserver();
} }
render () { 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>` : '' } ${ this.model.ui.get('chat-content-spinner-top') ? html`<span class="spinner fa fa-spinner centered"></span>` : '' }
<converse-message-history <converse-message-history
.model=${this.model} .model=${this.model}
.observer=${this.observer}
.messages=${[...this.model.messages.models]}> .messages=${[...this.model.messages.models]}>
</converse-message-history> </converse-message-history>
<div class="chat-content__notifications">${this.model.getNotificationsText()}</div> <div class="chat-content__notifications">${this.model.getNotificationsText()}</div>
@ -58,19 +63,62 @@ export default class ChatContent extends CustomElement {
this.debouncedMaintainScroll(); this.debouncedMaintainScroll();
} }
saveScrollPosition () { initIntersectionObserver () {
const scrollTop = this.scrollTop; if (this.observer) {
if (scrollTop) { this.observer.disconnect();
u.safeSave(this.model, { 'scrolled': true, scrollTop }); } 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 () { maintainScrollPosition () {
if (this.was_scrolled_up) { if (this.was_scrolled_up) {
const pos = this.model.get('scrollTop'); console.warn('scrolling into view');
if (pos) { this.anchored_message?.scrollIntoView(true);
this.scrollTop = pos;
}
} else { } else {
this.scrollDown(); this.scrollDown();
} }

View File

@ -54,16 +54,16 @@ export default class EmojiPickerContent extends CustomElement {
sizzle('.emoji-picker', this).forEach(a => this.observer.observe(a)); sizzle('.emoji-picker', this).forEach(a => this.observer.observe(a));
} }
setCategoryOnVisibilityChange (ev) { setCategoryOnVisibilityChange (entries) {
const selected = this.parentElement.navigator.selected; 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; let current;
// Choose the intersection that contains the currently selected // Choose the intersection that contains the currently selected
// element, or otherwise the one with the largest ratio. // element, or otherwise the one with the largest ratio.
if (intersection_with_selected) { if (intersection_with_selected) {
current = intersection_with_selected; current = intersection_with_selected;
} else { } 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) { if (current && current.isIntersecting) {
const category = current.target.getAttribute('data-category'); const category = current.target.getAttribute('data-category');

View File

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

View File

@ -24,7 +24,8 @@ export default class Message extends CustomElement {
static get properties () { static get properties () {
return { return {
jid: { type: String }, 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 () { getProps () {
return Object.assign( return Object.assign(
this.model.toJSON(), this.model.toJSON(),

12
src/shared/chat/utils.js Normal file
View File

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