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));
};
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 */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>`

View File

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